AuthResultData configures response headers (#41775) (#41948)

* extend AuthResultData with response headers

* add tests

* update docs

* rename headers --> requestHeaders to clarify intention

* update docs

* address comments
This commit is contained in:
Mikhail Shustov 2019-07-25 08:50:49 +02:00 committed by GitHub
parent c6345ac995
commit 2dcc69c4fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 295 additions and 76 deletions

View file

@ -9,5 +9,5 @@ Auth Headers map
<b>Signature:</b>
```typescript
export declare type AuthHeaders = Record<string, string>;
export declare type AuthHeaders = Record<string, string | string[]>;
```

View file

@ -1,13 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md) &gt; [headers](./kibana-plugin-server.authresultdata.headers.md)
## AuthResultData.headers property
Auth specific headers to authenticate a user against Elasticsearch.
<b>Signature:</b>
```typescript
headers: AuthHeaders;
```

View file

@ -1,21 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md)
## AuthResultData interface
Result of an incoming request authentication.
<b>Signature:</b>
```typescript
export interface AuthResultData
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [headers](./kibana-plugin-server.authresultdata.headers.md) | <code>AuthHeaders</code> | Auth specific headers to authenticate a user against Elasticsearch. |
| [state](./kibana-plugin-server.authresultdata.state.md) | <code>Record&lt;string, any&gt;</code> | Data to associate with an incoming request. Any downstream plugin may get access to the data. |

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultParams](./kibana-plugin-server.authresultparams.md)
## AuthResultParams interface
Result of an incoming request authentication.
<b>Signature:</b>
```typescript
export interface AuthResultParams
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [requestHeaders](./kibana-plugin-server.authresultparams.requestheaders.md) | <code>AuthHeaders</code> | Auth specific headers to attach to a request object. Used to perform a request to Elasticsearch on behalf of an authenticated user. |
| [responseHeaders](./kibana-plugin-server.authresultparams.responseheaders.md) | <code>AuthHeaders</code> | Auth specific headers to attach to a response object. Used to send back authentication mechanism related headers to a client when needed. |
| [state](./kibana-plugin-server.authresultparams.state.md) | <code>Record&lt;string, any&gt;</code> | Data to associate with an incoming request. Any downstream plugin may get access to the data. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultParams](./kibana-plugin-server.authresultparams.md) &gt; [requestHeaders](./kibana-plugin-server.authresultparams.requestheaders.md)
## AuthResultParams.requestHeaders property
Auth specific headers to attach to a request object. Used to perform a request to Elasticsearch on behalf of an authenticated user.
<b>Signature:</b>
```typescript
requestHeaders?: AuthHeaders;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultParams](./kibana-plugin-server.authresultparams.md) &gt; [responseHeaders](./kibana-plugin-server.authresultparams.responseheaders.md)
## AuthResultParams.responseHeaders property
Auth specific headers to attach to a response object. Used to send back authentication mechanism related headers to a client when needed.
<b>Signature:</b>
```typescript
responseHeaders?: AuthHeaders;
```

View file

@ -1,13 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultData](./kibana-plugin-server.authresultdata.md) &gt; [state](./kibana-plugin-server.authresultdata.state.md)
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [AuthResultParams](./kibana-plugin-server.authresultparams.md) &gt; [state](./kibana-plugin-server.authresultparams.state.md)
## AuthResultData.state property
## AuthResultParams.state property
Data to associate with an incoming request. Any downstream plugin may get access to the data.
<b>Signature:</b>
```typescript
state: Record<string, any>;
state?: Record<string, any>;
```

View file

@ -9,5 +9,5 @@ Authentication is successful with given credentials, allow request to pass throu
<b>Signature:</b>
```typescript
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
authenticated: (data?: AuthResultParams) => AuthResult;
```

View file

@ -16,7 +16,7 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(data?: Partial&lt;AuthResultData&gt;) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(data?: AuthResultParams) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | <code>(url: string) =&gt; AuthResult</code> | Authentication requires to interrupt request handling and redirect to a configured url |
| [rejected](./kibana-plugin-server.authtoolkit.rejected.md) | <code>(error: Error, options?: {</code><br/><code> statusCode?: number;</code><br/><code> }) =&gt; AuthResult</code> | Authentication is unsuccessful, fail the request with specified error. |

View file

@ -25,7 +25,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| Interface | Description |
| --- | --- |
| [AuthResultData](./kibana-plugin-server.authresultdata.md) | Result of an incoming request authentication. |
| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of an incoming request authentication. |
| [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. |
| [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. |
| [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins <code>setup</code> method. |

View file

@ -916,6 +916,99 @@ describe('setup contract', () => {
expect(fromRegisterOnPostAuth).toEqual({});
expect(fromRouteHandler).toEqual({});
});
it('attach security header to a successful response', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok({ header: 'ok' }));
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200);
expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
it('attach security header to an error response', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.badRequest(new Error('reason')));
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(400);
expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
// TODO un-skip when NP ResponseFactory supports configuring custom headers
it.skip('logs warning if Auth Security Header rewrites response header for success response', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok({}));
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200);
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot();
});
it.skip('logs warning if Auth Security Header rewrites response header for error response', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.badRequest(new Error('reason')));
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(400);
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot();
});
});
describe('#auth.isAuthenticated()', () => {

View file

@ -25,7 +25,7 @@ import { createServer, getListenerOptions, getServerOptions } from './http_tools
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { Router, KibanaRequest } from './router';
import { Router, KibanaRequest, ResponseHeaders } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
@ -90,11 +90,13 @@ export class HttpServer {
private readonly log: Logger;
private readonly authState: AuthStateStorage;
private readonly authHeaders: AuthHeadersStorage;
private readonly authRequestHeaders: AuthHeadersStorage;
private readonly authResponseHeaders: AuthHeadersStorage;
constructor(private readonly logger: LoggerFactory, private readonly name: string) {
this.authState = new AuthStateStorage(() => this.authRegistered);
this.authHeaders = new AuthHeadersStorage();
this.authRequestHeaders = new AuthHeadersStorage();
this.authResponseHeaders = new AuthHeadersStorage();
this.log = logger.get('http', 'server', name);
}
@ -131,7 +133,7 @@ export class HttpServer {
auth: {
get: this.authState.get,
isAuthenticated: this.authState.isAuthenticated,
getAuthHeaders: this.authHeaders.get,
getAuthHeaders: this.authRequestHeaders.get,
},
isTlsEnabled: config.ssl.enabled,
// Return server instance with the connection options so that we can properly
@ -247,12 +249,19 @@ export class HttpServer {
this.authRegistered = true;
this.server.auth.scheme('login', () => ({
authenticate: adoptToHapiAuthFormat(fn, (req, { state, headers }) => {
authenticate: adoptToHapiAuthFormat(fn, (req, { state, requestHeaders, responseHeaders }) => {
this.authState.set(req, state);
this.authHeaders.set(req, headers);
// we mutate headers only for the backward compatibility with the legacy platform.
// where some plugin read directly from headers to identify whether a user is authenticated.
Object.assign(req.headers, headers);
if (responseHeaders) {
this.authResponseHeaders.set(req, responseHeaders);
}
if (requestHeaders) {
this.authRequestHeaders.set(req, requestHeaders);
// we mutate headers only for the backward compatibility with the legacy platform.
// where some plugin read directly from headers to identify whether a user is authenticated.
Object.assign(req.headers, requestHeaders);
}
}),
}));
this.server.auth.strategy('session', 'login');
@ -262,5 +271,40 @@ export class HttpServer {
// should be applied for all routes if they don't specify auth strategy in route declaration
// https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions
this.server.auth.default('session');
this.server.ext('onPreResponse', (request, t) => {
const authResponseHeaders = this.authResponseHeaders.get(request);
this.extendResponseWithHeaders(request, authResponseHeaders);
return t.continue;
});
}
private extendResponseWithHeaders(request: Request, headers?: ResponseHeaders) {
const response = request.response;
if (!headers || !response) return;
if (response instanceof Error) {
this.findHeadersIntersection(response.output.headers, headers);
// hapi wraps all error response in Boom object internally
response.output.headers = {
...response.output.headers,
...(headers as any), // hapi types don't specify string[] as valid value
};
} else {
for (const [headerName, headerValue] of Object.entries(headers)) {
this.findHeadersIntersection(response.headers, headers);
response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value
}
}
}
// NOTE: responseHeaders contains not a full list of response headers, but only explicitly set on a response object.
// any headers added by hapi internally, like `content-type`, `content-length`, etc. do not present here.
private findHeadersIntersection(responseHeaders: ResponseHeaders, headers: ResponseHeaders) {
Object.keys(headers).forEach(headerName => {
if (responseHeaders[headerName] !== undefined) {
this.log.warn(`Server rewrites a response header [${headerName}].`);
}
});
}
}

View file

@ -30,6 +30,11 @@ export {
} from './router';
export { BasePathProxyServer } from './base_path_proxy_server';
export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
export { AuthenticationHandler, AuthHeaders, AuthResultData, AuthToolkit } from './lifecycle/auth';
export {
AuthenticationHandler,
AuthHeaders,
AuthResultParams,
AuthToolkit,
} from './lifecycle/auth';
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
export { SessionStorageFactory, SessionStorage } from './session_storage';

View file

@ -101,7 +101,7 @@ describe('http service', () => {
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated({
state: user,
headers: {
requestHeaders: {
authorization: token,
},
});
@ -167,7 +167,7 @@ describe('http service', () => {
const { registerAuth, registerRouter } = http;
await registerAuth((req, t) => {
return t.authenticated({ headers: authHeaders });
return t.authenticated({ requestHeaders: authHeaders });
});
const router = new Router('/new-platform');
@ -187,7 +187,7 @@ describe('http service', () => {
expect(headers).toEqual(authHeaders);
});
it('pass request authorization header to Elasticsearch if registerAuth was not set', async () => {
it('passes request authorization header to Elasticsearch if registerAuth was not set', async () => {
const authorizationHeader = 'Basic: username:password';
const { http, elasticsearch } = await root.setup();
const { registerRouter } = http;
@ -214,6 +214,56 @@ describe('http service', () => {
authorization: authorizationHeader,
});
});
it('attach security header to a successful response handled by Legacy platform', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { http } = await root.setup();
const { registerAuth } = http;
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: '/legacy',
handler: () => 'ok',
});
const response = await kbnTestServer.request.get(root, '/legacy').expect(200);
expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
it('attach security header to an error response handled by Legacy platform', async () => {
const authResponseHeader = {
'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca',
};
const { http } = await root.setup();
const { registerAuth } = http;
await registerAuth((req, t) => {
return t.authenticated({ responseHeaders: authResponseHeader });
});
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: '/legacy',
handler: () => {
throw Boom.badRequest();
},
});
const response = await kbnTestServer.request.get(root, '/legacy').expect(400);
expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']);
});
});
describe('#registerOnPostAuth()', () => {

View file

@ -25,7 +25,7 @@ describe('adoptToHapiAuthFormat', () => {
it('allows to associate arbitrary data with an incoming request', async () => {
const authData = {
state: { foo: 'bar' },
headers: { authorization: 'baz' },
requestHeaders: { authorization: 'baz' },
};
const authenticatedMock = jest.fn();
const onSuccessMock = jest.fn();

View file

@ -27,7 +27,7 @@ enum ResultType {
rejected = 'rejected',
}
interface Authenticated extends AuthResultData {
interface Authenticated extends AuthResultParams {
type: ResultType.authenticated;
}
@ -45,11 +45,12 @@ interface Rejected {
type AuthResult = Authenticated | Rejected | Redirected;
const authResult = {
authenticated(data: Partial<AuthResultData> = {}): AuthResult {
authenticated(data: Partial<AuthResultParams> = {}): AuthResult {
return {
type: ResultType.authenticated,
state: data.state || {},
headers: data.headers || {},
state: data.state,
requestHeaders: data.requestHeaders,
responseHeaders: data.responseHeaders,
};
},
redirected(url: string): AuthResult {
@ -82,21 +83,27 @@ const authResult = {
* @public
* */
export type AuthHeaders = Record<string, string>;
export type AuthHeaders = Record<string, string | string[]>;
/**
* Result of an incoming request authentication.
* @public
* */
export interface AuthResultData {
export interface AuthResultParams {
/**
* Data to associate with an incoming request. Any downstream plugin may get access to the data.
*/
state: Record<string, any>;
state?: Record<string, any>;
/**
* Auth specific headers to authenticate a user against Elasticsearch.
* Auth specific headers to attach to a request object.
* Used to perform a request to Elasticsearch on behalf of an authenticated user.
*/
headers: AuthHeaders;
requestHeaders?: AuthHeaders;
/**
* Auth specific headers to attach to a response object.
* Used to send back authentication mechanism related headers to a client when needed.
*/
responseHeaders?: AuthHeaders;
}
/**
@ -105,7 +112,7 @@ export interface AuthResultData {
*/
export interface AuthToolkit {
/** Authentication is successful with given credentials, allow request to pass through */
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
authenticated: (data?: AuthResultParams) => AuthResult;
/** Authentication requires to interrupt request handling and redirect to a configured url */
redirected: (url: string) => AuthResult;
/** Authentication is unsuccessful, fail the request with specified error. */
@ -127,7 +134,7 @@ export type AuthenticationHandler = (
/** @public */
export function adoptToHapiAuthFormat(
fn: AuthenticationHandler,
onSuccess: (req: Request, data: AuthResultData) => void = noop
onSuccess: (req: Request, data: AuthResultParams) => void = noop
) {
return async function interceptAuth(
req: Request,
@ -141,7 +148,11 @@ export function adoptToHapiAuthFormat(
);
}
if (authResult.isAuthenticated(result)) {
onSuccess(req, { state: result.state, headers: result.headers });
onSuccess(req, {
state: result.state,
requestHeaders: result.requestHeaders,
responseHeaders: result.responseHeaders,
});
return h.authenticated({ credentials: result.state || {} });
}
if (authResult.isRedirected(result)) {

View file

@ -21,6 +21,7 @@ import { pick } from '../../../utils';
/** @public */
export type Headers = Record<string, string | string[] | undefined>;
export type ResponseHeaders = Record<string, string | string[]>;
const normalizeHeaderField = (field: string) => field.trim().toLowerCase();

View file

@ -17,7 +17,7 @@
* under the License.
*/
export { Headers, filterHeaders } from './headers';
export { Headers, filterHeaders, ResponseHeaders } from './headers';
export { Router } from './router';
export { KibanaRequest, KibanaRequestRoute, ensureRawRequest, isRealRequest } from './request';
export { RouteMethod, RouteConfigOptions } from './route';

View file

@ -61,7 +61,7 @@ export {
export {
AuthenticationHandler,
AuthHeaders,
AuthResultData,
AuthResultParams,
AuthToolkit,
GetAuthHeaders,
KibanaRequest,

View file

@ -28,17 +28,18 @@ export type APICaller = (endpoint: string, clientParams: Record<string, any>, op
export type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
// @public
export type AuthHeaders = Record<string, string>;
export type AuthHeaders = Record<string, string | string[]>;
// @public
export interface AuthResultData {
headers: AuthHeaders;
state: Record<string, any>;
export interface AuthResultParams {
requestHeaders?: AuthHeaders;
responseHeaders?: AuthHeaders;
state?: Record<string, any>;
}
// @public
export interface AuthToolkit {
authenticated: (data?: Partial<AuthResultData>) => AuthResult;
authenticated: (data?: AuthResultParams) => AuthResult;
redirected: (url: string) => AuthResult;
rejected: (error: Error, options?: {
statusCode?: number;

View file

@ -158,7 +158,7 @@ describe('setupAuthentication()', () => {
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({
state: mockUser,
headers: mockAuthHeaders,
requestHeaders: mockAuthHeaders,
});
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();

View file

@ -107,7 +107,7 @@ export async function setupAuthentication({
if (authenticationResult.succeeded()) {
return t.authenticated({
state: authenticationResult.user,
headers: authenticationResult.authHeaders,
requestHeaders: authenticationResult.authHeaders,
});
}