Unify response interface in handler and request interceptors (#42442)

* add response factory to the interceptors

* adopt x-pack code to the changes

* Add a separate response factory for lifecycles.

Only route handler can respond with 2xx response.
Interceptors may redirect or reject an incoming request.

* re-generate docs

* response.internal --> response.internalError

* use internalError for exceptions in authenticator

* before Security plugin proxied ES error status code. now sets explicitly.

* provide error via message field of error response for BWC

* update docs

* add customError response

* restore integration test and update unit tests

* update docs

* support Hapi error format for BWC

* add a couple of tests
This commit is contained in:
Mikhail Shustov 2019-08-08 12:07:43 +02:00 committed by GitHub
parent 35528af8ef
commit 06adc737d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1896 additions and 1708 deletions

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export declare type AuthenticationHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit) => AuthResult | KibanaResponse | Promise<AuthResult | KibanaResponse>;
```

View file

@ -17,6 +17,4 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [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

@ -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; [AuthToolkit](./kibana-plugin-server.authtoolkit.md) &gt; [redirected](./kibana-plugin-server.authtoolkit.redirected.md)
## AuthToolkit.redirected property
Authentication requires to interrupt request handling and redirect to a configured url
<b>Signature:</b>
```typescript
redirected: (url: string) => AuthResult;
```

View file

@ -1,15 +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; [AuthToolkit](./kibana-plugin-server.authtoolkit.md) &gt; [rejected](./kibana-plugin-server.authtoolkit.rejected.md)
## AuthToolkit.rejected property
Authentication is unsuccessful, fail the request with specified error.
<b>Signature:</b>
```typescript
rejected: (error: Error, options?: {
statusCode?: number;
}) => AuthResult;
```

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; [LifecycleResponseFactory](./kibana-plugin-server.lifecycleresponsefactory.md)
## LifecycleResponseFactory type
Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client.
<b>Signature:</b>
```typescript
export declare type LifecycleResponseFactory = typeof lifecycleResponseFactory;
```

View file

@ -120,6 +120,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) | Return authentication status for a request. |
| [KibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) | Creates an object containing request response payload, HTTP headers, error details, and other data transmitted to the client. |
| [KnownHeaders](./kibana-plugin-server.knownheaders.md) | Set of well-known HTTP headers. |
| [LifecycleResponseFactory](./kibana-plugin-server.lifecycleresponsefactory.md) | Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client. |
| [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | |
| [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | |
| [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>server</code> directory should conform to this interface. |

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type OnPostAuthHandler<Params = any, Query = any, Body = any> = (request: KibanaRequest<Params, Query, Body>, t: OnPostAuthToolkit) => OnPostAuthResult | Promise<OnPostAuthResult>;
export declare type OnPostAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPostAuthToolkit) => OnPostAuthResult | KibanaResponse | Promise<OnPostAuthResult | KibanaResponse>;
```

View file

@ -17,6 +17,4 @@ export interface OnPostAuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [next](./kibana-plugin-server.onpostauthtoolkit.next.md) | <code>() =&gt; OnPostAuthResult</code> | To pass request to the next handler |
| [redirected](./kibana-plugin-server.onpostauthtoolkit.redirected.md) | <code>(url: string) =&gt; OnPostAuthResult</code> | To interrupt request handling and redirect to a configured url |
| [rejected](./kibana-plugin-server.onpostauthtoolkit.rejected.md) | <code>(error: Error, options?: {</code><br/><code> statusCode?: number;</code><br/><code> }) =&gt; OnPostAuthResult</code> | Fail the request with specified error. |

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; [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) &gt; [redirected](./kibana-plugin-server.onpostauthtoolkit.redirected.md)
## OnPostAuthToolkit.redirected property
To interrupt request handling and redirect to a configured url
<b>Signature:</b>
```typescript
redirected: (url: string) => OnPostAuthResult;
```

View file

@ -1,15 +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; [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) &gt; [rejected](./kibana-plugin-server.onpostauthtoolkit.rejected.md)
## OnPostAuthToolkit.rejected property
Fail the request with specified error.
<b>Signature:</b>
```typescript
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnPostAuthResult;
```

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type OnPreAuthHandler<Params = any, Query = any, Body = any> = (request: KibanaRequest<Params, Query, Body>, t: OnPreAuthToolkit) => OnPreAuthResult | Promise<OnPreAuthResult>;
export declare type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit) => OnPreAuthResult | KibanaResponse | Promise<OnPreAuthResult | KibanaResponse>;
```

View file

@ -17,6 +17,5 @@ export interface OnPreAuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [next](./kibana-plugin-server.onpreauthtoolkit.next.md) | <code>() =&gt; OnPreAuthResult</code> | To pass request to the next handler |
| [redirected](./kibana-plugin-server.onpreauthtoolkit.redirected.md) | <code>(url: string, options?: {</code><br/><code> forward: boolean;</code><br/><code> }) =&gt; OnPreAuthResult</code> | To interrupt request handling and redirect to a configured url. If "options.forwarded" = true, request will be forwarded to another url right on the server. |
| [rejected](./kibana-plugin-server.onpreauthtoolkit.rejected.md) | <code>(error: Error, options?: {</code><br/><code> statusCode?: number;</code><br/><code> }) =&gt; OnPreAuthResult</code> | Fail the request with specified error. |
| [rewriteUrl](./kibana-plugin-server.onpreauthtoolkit.rewriteurl.md) | <code>(url: string) =&gt; OnPreAuthResult</code> | Rewrite requested resources url before is was authenticated and routed to a handler |

View file

@ -1,15 +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; [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) &gt; [redirected](./kibana-plugin-server.onpreauthtoolkit.redirected.md)
## OnPreAuthToolkit.redirected property
To interrupt request handling and redirect to a configured url. If "options.forwarded" = true, request will be forwarded to another url right on the server.
<b>Signature:</b>
```typescript
redirected: (url: string, options?: {
forward: boolean;
}) => OnPreAuthResult;
```

View file

@ -1,15 +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; [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) &gt; [rejected](./kibana-plugin-server.onpreauthtoolkit.rejected.md)
## OnPreAuthToolkit.rejected property
Fail the request with specified error.
<b>Signature:</b>
```typescript
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnPreAuthResult;
```

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; [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) &gt; [rewriteUrl](./kibana-plugin-server.onpreauthtoolkit.rewriteurl.md)
## OnPreAuthToolkit.rewriteUrl property
Rewrite requested resources url before is was authenticated and routed to a handler
<b>Signature:</b>
```typescript
rewriteUrl: (url: string) => OnPreAuthResult;
```

View file

@ -16,5 +16,5 @@ constructor(path: string);
| Parameter | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | a router path, set as the very first path segment for all registered routes. |
| path | <code>string</code> | |

View file

@ -23,7 +23,6 @@ export declare class Router
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
| [path](./kibana-plugin-server.router.path.md) | | <code>string</code> | |
| [routes](./kibana-plugin-server.router.routes.md) | | <code>Array&lt;Readonly&lt;RouterRoute&gt;&gt;</code> | |
## Methods

View file

@ -1,11 +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; [Router](./kibana-plugin-server.router.md) &gt; [routes](./kibana-plugin-server.router.routes.md)
## Router.routes property
<b>Signature:</b>
```typescript
routes: Array<Readonly<RouterRoute>>;
```

View file

@ -16,14 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Request, ResponseToolkit } from 'hapi';
import { Request } from 'hapi';
import { merge } from 'lodash';
import querystring from 'querystring';
import { schema } from '@kbn/config-schema';
import { KibanaRequest, RouteMethod } from './router';
import {
KibanaRequest,
LifecycleResponseFactory,
RouteMethod,
KibanaResponseFactory,
} from './router';
interface RequestFixtureOptions {
headers?: Record<string, string>;
@ -97,12 +102,35 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) {
) as Request;
}
function createRawResponseToolkitMock(customization: DeepPartial<ResponseToolkit> = {}) {
return merge({}, customization) as ResponseToolkit;
}
const createResponseFactoryMock = (): jest.Mocked<KibanaResponseFactory> => ({
ok: jest.fn(),
accepted: jest.fn(),
noContent: jest.fn(),
custom: jest.fn(),
redirected: jest.fn(),
badRequest: jest.fn(),
unauthorized: jest.fn(),
forbidden: jest.fn(),
notFound: jest.fn(),
conflict: jest.fn(),
internalError: jest.fn(),
customError: jest.fn(),
});
const createLifecycleResponseFactoryMock = (): jest.Mocked<LifecycleResponseFactory> => ({
redirected: jest.fn(),
badRequest: jest.fn(),
unauthorized: jest.fn(),
forbidden: jest.fn(),
notFound: jest.fn(),
conflict: jest.fn(),
internalError: jest.fn(),
customError: jest.fn(),
});
export const httpServerMock = {
createKibanaRequest: createKibanaRequestMock,
createRawRequest: createRawRequestMock,
createRawResponseToolkit: createRawResponseToolkitMock,
createResponseFactory: createResponseFactoryMock,
createLifecycleResponseFactory: createLifecycleResponseFactoryMock,
};

View file

@ -18,8 +18,6 @@
*/
import { Server } from 'http';
import request from 'request';
import Boom from 'boom';
jest.mock('fs', () => ({
readFileSync: jest.fn(),
@ -40,16 +38,6 @@ const cookieOptions = {
isSecure: false,
};
interface User {
id: string;
roles?: string[];
}
interface StorageData {
value: User;
expires: number;
}
const chance = new Chance();
let server: HttpServer;
@ -159,7 +147,9 @@ test('invalid params', async () => {
.expect(400)
.then(res => {
expect(res.body).toEqual({
error: '[request params.test]: expected value of type [number] but got [string]',
error: 'Bad Request',
statusCode: 400,
message: '[request params.test]: expected value of type [number] but got [string]',
});
});
});
@ -222,7 +212,9 @@ test('invalid query', async () => {
.expect(400)
.then(res => {
expect(res.body).toEqual({
error: '[request query.bar]: expected value of type [number] but got [string]',
error: 'Bad Request',
statusCode: 400,
message: '[request query.bar]: expected value of type [number] but got [string]',
});
});
});
@ -290,7 +282,9 @@ test('invalid body', async () => {
.expect(400)
.then(res => {
expect(res.body).toEqual({
error: '[request body.bar]: expected value of type [number] but got [string]',
error: 'Bad Request',
statusCode: 400,
message: '[request body.bar]: expected value of type [number] but got [string]',
});
});
});
@ -498,78 +492,12 @@ test('returns server and connection options on start', async () => {
expect(innerServer).toBe((server as any).server);
});
test('registers registerOnPostAuth interceptor several times', async () => {
const { registerOnPostAuth } = await server.setup(config);
const doRegister = () => registerOnPostAuth(() => null as any);
doRegister();
expect(doRegister).not.toThrowError();
});
test('throws an error if starts without set up', async () => {
await expect(server.start()).rejects.toThrowErrorMatchingInlineSnapshot(
`"Http server is not setup up yet"`
);
});
test('enables auth for a route by default if registerAuth has been called', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn().mockImplementation((req, t) => t.authenticated());
registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: true });
expect(authenticate).toHaveBeenCalledTimes(1);
});
test('supports disabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false, options: { authRequired: false } }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn();
registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: false });
expect(authenticate).toHaveBeenCalledTimes(0);
});
test('supports enabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false, options: { authRequired: true } }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn().mockImplementation((req, t) => t.authenticated({}));
await registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: true });
expect(authenticate).toHaveBeenCalledTimes(1);
});
test('allows attaching metadata to attach meta-data tag strings to a route', async () => {
const tags = ['my:tag'];
const { registerRouter, server: innerServer } = await server.setup(config);
@ -629,307 +557,6 @@ describe('setup contract', () => {
expect(create()).rejects.toThrowError('A cookieSessionStorageFactory was already created');
});
});
describe('#registerAuth', () => {
it('registers auth request interceptor only once', async () => {
const { registerAuth } = await server.setup(config);
const doRegister = () => registerAuth(() => null as any);
doRegister();
expect(doRegister).toThrowError('Auth interceptor was already registered');
});
it('may grant access to a resource', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
await registerAuth((req, t) => t.authenticated());
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { content: 'ok' });
});
it('supports rejecting a request from an unauthenticated user', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
await registerAuth((req, t) => t.rejected(Boom.unauthorized()));
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(401);
});
it('supports redirecting', async () => {
const redirectTo = '/redirect-url';
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
await registerAuth((req, t) => {
return t.redirected(redirectTo);
});
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(302);
expect(response.header.location).toBe(redirectTo);
});
it(`doesn't expose internal error details`, async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
await registerAuth((req, t) => {
throw new Error('sensitive info');
});
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred',
});
});
it('allows manipulating cookies via cookie session storage', async () => {
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
const {
createCookieSessionStorageFactory,
registerAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
const sessionStorageFactory = await createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
registerAuth((req, t) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return t.authenticated({ state: user });
});
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200, { content: 'ok' });
expect(response.header['set-cookie']).toBeDefined();
const cookies = response.header['set-cookie'];
expect(cookies).toHaveLength(1);
const sessionCookie = request.cookie(cookies[0]);
if (!sessionCookie) {
throw new Error('session cookie expected to be defined');
}
expect(sessionCookie).toBeDefined();
expect(sessionCookie.key).toBe('sid');
expect(sessionCookie.value).toBeDefined();
expect(sessionCookie.path).toBe('/');
expect(sessionCookie.httpOnly).toBe(true);
});
it('allows manipulating cookies from route handler', async () => {
const {
createCookieSessionStorageFactory,
registerAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
const sessionStorageFactory = await createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
registerAuth((req, t) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return t.authenticated();
});
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => res.ok({ content: 'ok' }));
router.get({ path: '/with-cookie', validate: false }, (req, res) => {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.clear();
return res.ok({ content: 'ok' });
});
registerRouter(router);
await server.start();
const responseToSetCookie = await supertest(innerServer.listener)
.get('/')
.expect(200);
expect(responseToSetCookie.header['set-cookie']).toBeDefined();
const responseToResetCookie = await supertest(innerServer.listener)
.get('/with-cookie')
.expect(200);
expect(responseToResetCookie.header['set-cookie']).toEqual([
'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/',
]);
});
it.skip('is the only place with access to the authorization header', async () => {
const token = 'Basic: user:password';
const {
registerAuth,
registerOnPreAuth,
registerOnPostAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
let fromRegisterOnPreAuth;
await registerOnPreAuth((req, t) => {
fromRegisterOnPreAuth = req.headers.authorization;
return t.next();
});
let fromRegisterAuth;
await registerAuth((req, t) => {
fromRegisterAuth = req.headers.authorization;
return t.authenticated();
});
let fromRegisterOnPostAuth;
await registerOnPostAuth((req, t) => {
fromRegisterOnPostAuth = req.headers.authorization;
return t.next();
});
let fromRouteHandler;
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => {
fromRouteHandler = req.headers.authorization;
return res.ok({ content: 'ok' });
});
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.set('Authorization', token)
.expect(200);
expect(fromRegisterOnPreAuth).toEqual({});
expect(fromRegisterAuth).toEqual({ authorization: token });
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()', () => {
it('returns true if has been authorized', async () => {
@ -943,7 +570,7 @@ describe('setup contract', () => {
);
registerRouter(router);
await registerAuth((req, t) => t.authenticated());
await registerAuth((req, res, toolkit) => toolkit.authenticated());
await server.start();
await supertest(innerServer.listener)
@ -962,7 +589,7 @@ describe('setup contract', () => {
);
registerRouter(router);
await registerAuth((req, t) => t.authenticated());
await registerAuth((req, res, toolkit) => toolkit.authenticated());
await server.start();
await supertest(innerServer.listener)
@ -997,9 +624,9 @@ describe('setup contract', () => {
auth,
} = await server.setup(config);
const sessionStorageFactory = await createCookieSessionStorageFactory(cookieOptions);
registerAuth((req, t) => {
registerAuth((req, res, toolkit) => {
sessionStorageFactory.asScoped(req).set({ value: user, expires: Date.now() + 1000 });
return t.authenticated({ state: user });
return toolkit.authenticated({ state: user });
});
const router = new Router('');

View file

@ -281,14 +281,14 @@ export class HttpServer {
return;
}
this.registerOnPreAuth((request, toolkit) => {
this.registerOnPreAuth((request, response, toolkit) => {
const oldUrl = request.url.href!;
const newURL = basePathService.remove(oldUrl);
const shouldRedirect = newURL !== oldUrl;
if (shouldRedirect) {
return toolkit.redirected(newURL, { forward: true });
return toolkit.rewriteUrl(newURL);
}
return toolkit.rejected(new Error('not found'), { statusCode: 404 });
return response.notFound(new Error('not found'));
});
}
@ -304,7 +304,7 @@ export class HttpServer {
throw new Error('Server is not created yet');
}
this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn));
this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log));
}
private registerOnPreAuth(fn: OnPreAuthHandler) {
@ -312,7 +312,7 @@ export class HttpServer {
throw new Error('Server is not created yet');
}
this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn));
this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log));
}
private async createCookieSessionStorageFactory<T>(
@ -345,20 +345,24 @@ export class HttpServer {
this.authRegistered = true;
this.server.auth.scheme('login', () => ({
authenticate: adoptToHapiAuthFormat(fn, (req, { state, requestHeaders, responseHeaders }) => {
this.authState.set(req, state);
authenticate: adoptToHapiAuthFormat(
fn,
this.log,
(req, { state, requestHeaders, responseHeaders }) => {
this.authState.set(req, state);
if (responseHeaders) {
this.authResponseHeaders.set(req, responseHeaders);
}
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);
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');

View file

@ -18,11 +18,9 @@
*/
import { Server } from 'hapi';
import { HttpService } from './http_service';
import { HttpServiceSetup } from './http_service';
import { HttpService, HttpServiceSetup } from './http_service';
import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';
import { AuthToolkit } from './lifecycle/auth';
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
import { sessionStorageMock } from './cookie_session_storage.mocks';
type ServiceSetupMockType = jest.Mocked<HttpServiceSetup> & {
@ -72,20 +70,11 @@ const createHttpServiceMock = () => {
const createOnPreAuthToolkitMock = (): jest.Mocked<OnPreAuthToolkit> => ({
next: jest.fn(),
redirected: jest.fn(),
rejected: jest.fn(),
rewriteUrl: jest.fn(),
});
const createAuthToolkitMock = (): jest.Mocked<AuthToolkit> => ({
authenticated: jest.fn(),
redirected: jest.fn(),
rejected: jest.fn(),
});
const createOnPostAuthToolkitMock = (): jest.Mocked<OnPostAuthToolkit> => ({
next: jest.fn(),
redirected: jest.fn(),
rejected: jest.fn(),
});
export const httpServiceMock = {
@ -94,5 +83,4 @@ export const httpServiceMock = {
createSetupContract: createSetupContractMock,
createOnPreAuthToolkit: createOnPreAuthToolkitMock,
createAuthToolkit: createAuthToolkitMock,
createOnPostAuthToolkit: createOnPostAuthToolkitMock,
};

View file

@ -31,6 +31,7 @@ export {
KibanaRequestRoute,
KnownHeaders,
LegacyRequest,
LifecycleResponseFactory,
RedirectResponseOptions,
RequestHandler,
ResponseError,

View file

@ -0,0 +1,308 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { Request } from 'hapi';
import { first } from 'rxjs/operators';
import { clusterClientMock } from './core_service.test.mocks';
import { Router } from '../router';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
interface User {
id: string;
roles?: string[];
}
interface StorageData {
value: User;
expires: number;
}
describe('http service', () => {
describe('legacy server', () => {
describe('#registerAuth()', () => {
const sessionDurationMs = 1000;
const cookieOptions = {
name: 'sid',
encryptionKey: 'something_at_least_32_characters',
validate: (session: StorageData) => true,
isSecure: false,
path: '/',
};
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => {
clusterClientMock.mockClear();
await root.shutdown();
});
it('runs auth for legacy routes and proxy request to legacy server route handlers', async () => {
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, res, toolkit) => {
if (req.headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return toolkit.authenticated({ state: user });
} else {
return res.unauthorized();
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: () => 'ok from legacy server',
});
const response = await kbnTestServer.request
.get(root, legacyUrl)
.expect(200, 'ok from legacy server');
expect(response.header['set-cookie']).toHaveLength(1);
});
it('passes authHeaders as request headers to the legacy platform', async () => {
const token = 'Basic: name:password';
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, res, toolkit) => {
if (req.headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return toolkit.authenticated({
state: user,
requestHeaders: {
authorization: token,
},
});
} else {
return res.unauthorized();
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: (req: Request) => ({
authorization: req.headers.authorization,
custom: req.headers.custom,
}),
});
await kbnTestServer.request
.get(root, legacyUrl)
.set({ custom: 'custom-header' })
.expect(200, { authorization: token, custom: 'custom-header' });
});
it('passes associated auth state to Legacy platform', async () => {
const user = { id: '42' };
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, res, toolkit) => {
if (req.headers.authorization) {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return toolkit.authenticated({ state: user });
} else {
return res.unauthorized();
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: kbnServer.newPlatform.setup.core.http.auth.get,
});
const response = await kbnTestServer.request.get(root, legacyUrl).expect(200);
expect(response.body.state).toEqual(user);
expect(response.body.status).toEqual('authenticated');
expect(response.header['set-cookie']).toHaveLength(1);
});
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, res, toolkit) => {
return toolkit.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, res, toolkit) => {
return toolkit.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('#basePath()', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('basePath information for an incoming request is available in legacy server', async () => {
const reqBasePath = '/requests-specific-base-path';
const { http } = await root.setup();
http.registerOnPreAuth((req, res, toolkit) => {
http.basePath.set(req, reqBasePath);
return toolkit.next();
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: kbnServer.newPlatform.setup.core.http.basePath.get,
});
await kbnTestServer.request.get(root, legacyUrl).expect(200, reqBasePath);
});
});
});
describe('elasticsearch', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => {
clusterClientMock.mockClear();
await root.shutdown();
});
it('rewrites authorization header via authHeaders to make a request to Elasticsearch', async () => {
const authHeaders = { authorization: 'Basic: user:password' };
const { http, elasticsearch } = await root.setup();
const { registerAuth, registerRouter } = http;
await registerAuth((req, res, toolkit) =>
toolkit.authenticated({ requestHeaders: authHeaders })
);
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/new-platform/').expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual(authHeaders);
});
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;
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request
.get(root, '/new-platform/')
.set('Authorization', authorizationHeader)
.expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual({
authorization: authorizationHeader,
});
});
});
});

View file

@ -1,428 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { Request } from 'hapi';
import { first } from 'rxjs/operators';
import { clusterClientMock } from './http_service.test.mocks';
import { Router } from '../router';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
interface User {
id: string;
roles?: string[];
}
interface StorageData {
value: User;
expires: number;
}
describe('http service', () => {
describe('setup contract', () => {
describe('#registerAuth()', () => {
const sessionDurationMs = 1000;
const cookieOptions = {
name: 'sid',
encryptionKey: 'something_at_least_32_characters',
validate: (session: StorageData) => true,
isSecure: false,
path: '/',
};
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => {
clusterClientMock.mockClear();
await root.shutdown();
});
it('runs auth for legacy routes and proxy request to legacy server route handlers', async () => {
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, t) => {
if (req.headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated({ state: user });
} else {
return t.rejected(Boom.unauthorized());
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: () => 'ok from legacy server',
});
const response = await kbnTestServer.request
.get(root, legacyUrl)
.expect(200, 'ok from legacy server');
expect(response.header['set-cookie']).toHaveLength(1);
});
it('passes authHeaders as request headers to the legacy platform', async () => {
const token = 'Basic: name:password';
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, t) => {
if (req.headers.authorization) {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated({
state: user,
requestHeaders: {
authorization: token,
},
});
} else {
return t.rejected(Boom.unauthorized());
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: (req: Request) => ({
authorization: req.headers.authorization,
custom: req.headers.custom,
}),
});
await kbnTestServer.request
.get(root, legacyUrl)
.set({ custom: 'custom-header' })
.expect(200, { authorization: token, custom: 'custom-header' });
});
it('passes associated auth state to Legacy platform', async () => {
const user = { id: '42' };
const { http } = await root.setup();
const sessionStorageFactory = await http.createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
http.registerAuth((req, t) => {
if (req.headers.authorization) {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated({ state: user });
} else {
return t.rejected(Boom.unauthorized());
}
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: kbnServer.newPlatform.setup.core.http.auth.get,
});
const response = await kbnTestServer.request.get(root, legacyUrl).expect(200);
expect(response.body.state).toEqual(user);
expect(response.body.status).toEqual('authenticated');
expect(response.header['set-cookie']).toHaveLength(1);
});
it('rewrites authorization header via authHeaders to make a request to Elasticsearch', async () => {
const authHeaders = { authorization: 'Basic: user:password' };
const { http, elasticsearch } = await root.setup();
const { registerAuth, registerRouter } = http;
await registerAuth((req, t) => {
return t.authenticated({ requestHeaders: authHeaders });
});
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/new-platform/').expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual(authHeaders);
});
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;
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) => {
const client = await elasticsearch.dataClient$.pipe(first()).toPromise();
client.asScoped(req);
return res.ok({ header: 'ok' });
});
registerRouter(router);
await root.start();
await kbnTestServer.request
.get(root, '/new-platform/')
.set('Authorization', authorizationHeader)
.expect(200);
expect(clusterClientMock).toBeCalledTimes(1);
const [firstCall] = clusterClientMock.mock.calls;
const [, , headers] = firstCall;
expect(headers).toEqual({
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()', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('supports passing request through to the route handler', async () => {
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
const { http } = await root.setup();
http.registerOnPostAuth((req, t) => t.next());
http.registerOnPostAuth(async (req, t) => {
await Promise.resolve();
return t.next();
});
http.registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/new-platform/').expect(200, { content: 'ok' });
});
it('supports redirecting to configured url', async () => {
const redirectTo = '/redirect-url';
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => t.redirected(redirectTo));
await root.start();
const response = await kbnTestServer.request.get(root, '/new-platform/').expect(302);
expect(response.header.location).toBe(redirectTo);
});
it('fails a request with configured error and status code', async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) =>
t.rejected(new Error('unexpected error'), { statusCode: 400 })
);
await root.start();
await kbnTestServer.request
.get(root, '/new-platform/')
.expect(400, { statusCode: 400, error: 'Bad Request', message: 'unexpected error' });
});
it(`doesn't expose internal error details`, async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => {
throw new Error('sensitive info');
});
await root.start();
await kbnTestServer.request.get(root, '/new-platform/').expect({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred',
});
});
it(`doesn't share request object between interceptors`, async () => {
const { http } = await root.setup();
http.registerOnPostAuth(async (req, t) => {
// @ts-ignore. don't complain customField is not defined on Request type
req.customField = { value: 42 };
return t.next();
});
http.registerOnPostAuth((req, t) => {
// @ts-ignore don't complain customField is not defined on Request type
if (typeof req.customField !== 'undefined') {
throw new Error('Request object was mutated');
}
return t.next();
});
const router = new Router('/new-platform');
router.get({ path: '/', validate: false }, async (req, res) =>
// @ts-ignore. don't complain customField is not defined on Request type
res.ok({ customField: String(req.customField) })
);
http.registerRouter(router);
await root.start();
await kbnTestServer.request
.get(root, '/new-platform/')
.expect(200, { customField: 'undefined' });
});
});
describe('#registerOnPostAuth() toolkit', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('supports Url change on the flight', async () => {
const { http } = await root.setup();
http.registerOnPreAuth((req, t) => {
return t.redirected('/new-platform/new-url', { forward: true });
});
const router = new Router('/new-platform');
router.get({ path: '/new-url', validate: false }, async (req, res) =>
res.ok({ key: 'new-url-reached' })
);
http.registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/').expect(200, { key: 'new-url-reached' });
});
it('url re-write works for legacy server as well', async () => {
const { http } = await root.setup();
const newUrl = '/new-url';
http.registerOnPreAuth((req, t) => {
return t.redirected(newUrl, { forward: true });
});
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: newUrl,
handler: () => 'ok-from-legacy',
});
await kbnTestServer.request.get(root, '/').expect(200, 'ok-from-legacy');
});
});
describe('#basePath()', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('basePath information for an incoming request is available in legacy server', async () => {
const reqBasePath = '/requests-specific-base-path';
const { http } = await root.setup();
http.registerOnPreAuth((req, t) => {
http.basePath.set(req, reqBasePath);
return t.next();
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: kbnServer.newPlatform.setup.core.http.basePath.get,
});
await kbnTestServer.request.get(root, legacyUrl).expect(200, reqBasePath);
});
});
});
});

View file

@ -0,0 +1,923 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import supertest from 'supertest';
import { ByteSizeValue } from '@kbn/config-schema';
import request from 'request';
import { HttpConfig, Router } from '..';
import { ensureRawRequest } from '../router';
import { HttpServer } from '../http_server';
import { LoggerFactory } from '../../logging';
import { loggingServiceMock } from '../../logging/logging_service.mock';
let server: HttpServer;
let logger: LoggerFactory;
const config = {
host: '127.0.0.1',
maxPayload: new ByteSizeValue(1024),
port: 10001,
ssl: { enabled: false },
} as HttpConfig;
interface User {
id: string;
roles?: string[];
}
interface StorageData {
value: User;
expires: number;
}
beforeEach(() => {
logger = loggingServiceMock.create();
server = new HttpServer(logger, 'tests');
});
afterEach(async () => {
await server.stop();
});
describe('OnPreAuth', () => {
it('supports registering request inceptors', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok('ok'));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
const callingOrder: string[] = [];
registerOnPreAuth((req, res, t) => {
callingOrder.push('first');
return t.next();
});
registerOnPreAuth((req, res, t) => {
callingOrder.push('second');
return t.next();
});
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');
expect(callingOrder).toEqual(['first', 'second']);
});
it('supports request forwarding to specified url', async () => {
const router = new Router('/');
router.get({ path: '/initial', validate: false }, (req, res) => res.ok('initial'));
router.get({ path: '/redirectUrl', validate: false }, (req, res) => res.ok('redirected'));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
let urlBeforeForwarding;
registerOnPreAuth((req, res, t) => {
urlBeforeForwarding = ensureRawRequest(req).raw.req.url;
return t.rewriteUrl('/redirectUrl');
});
let urlAfterForwarding;
registerOnPreAuth((req, res, t) => {
// used by legacy platform
urlAfterForwarding = ensureRawRequest(req).raw.req.url;
return t.next();
});
await server.start();
await supertest(innerServer.listener)
.get('/initial')
.expect(200, 'redirected');
expect(urlBeforeForwarding).toBe('/initial');
expect(urlAfterForwarding).toBe('/redirectUrl');
});
it('supports redirection from the interceptor', async () => {
const router = new Router('/');
const redirectUrl = '/redirectUrl';
router.get({ path: '/initial', validate: false }, (req, res) => res.ok('initial'));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPreAuth((req, res, t) =>
res.redirected(undefined, {
headers: {
location: redirectUrl,
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/initial')
.expect(302);
expect(result.header.location).toBe(redirectUrl);
});
it('supports rejecting request and adjusting response headers', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPreAuth((req, res, t) =>
res.unauthorized('not found error', {
headers: {
'www-authenticate': 'challenge',
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(401);
expect(result.header['www-authenticate']).toBe('challenge');
});
it("doesn't expose error details if interceptor throws", async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPreAuth((req, res, t) => {
throw new Error('reason');
});
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: reason],
],
]
`);
});
it('returns internal error if interceptor returns unexpected result', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok('ok'));
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPreAuth((req, res, t) => ({} as any));
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: [object Object].],
],
]
`);
});
it(`doesn't share request object between interceptors`, async () => {
const { registerRouter, registerOnPreAuth, server: innerServer } = await server.setup(config);
registerOnPreAuth((req, res, t) => {
// @ts-ignore. don't complain customField is not defined on Request type
req.customField = { value: 42 };
return t.next();
});
registerOnPreAuth((req, res, t) => {
// @ts-ignore don't complain customField is not defined on Request type
if (typeof req.customField !== 'undefined') {
throw new Error('Request object was mutated');
}
return t.next();
});
const router = new Router('/');
router.get({ path: '/', validate: false }, async (req, res) =>
// @ts-ignore. don't complain customField is not defined on Request type
res.ok({ customField: String(req.customField) })
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { customField: 'undefined' });
});
});
describe('OnPostAuth', () => {
it('supports registering request inceptors', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok('ok'));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
const callingOrder: string[] = [];
registerOnPostAuth((req, res, t) => {
callingOrder.push('first');
return t.next();
});
registerOnPostAuth((req, res, t) => {
callingOrder.push('second');
return t.next();
});
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, 'ok');
expect(callingOrder).toEqual(['first', 'second']);
});
it('supports redirection from the interceptor', async () => {
const router = new Router('/');
const redirectUrl = '/redirectUrl';
router.get({ path: '/initial', validate: false }, (req, res) => res.ok('initial'));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) =>
res.redirected(undefined, {
headers: {
location: redirectUrl,
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/initial')
.expect(302);
expect(result.header.location).toBe(redirectUrl);
});
it('supports rejecting request and adjusting response headers', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) =>
res.unauthorized('not found error', {
headers: {
'www-authenticate': 'challenge',
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(401);
expect(result.header['www-authenticate']).toBe('challenge');
});
it("doesn't expose error details if interceptor throws", async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) => {
throw new Error('reason');
});
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: reason],
],
]
`);
});
it('returns internal error if interceptor returns unexpected result', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok('ok'));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) => ({} as any));
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: [object Object].],
],
]
`);
});
it(`doesn't share request object between interceptors`, async () => {
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerOnPostAuth((req, res, t) => {
// @ts-ignore. don't complain customField is not defined on Request type
req.customField = { value: 42 };
return t.next();
});
registerOnPostAuth((req, res, t) => {
// @ts-ignore don't complain customField is not defined on Request type
if (typeof req.customField !== 'undefined') {
throw new Error('Request object was mutated');
}
return t.next();
});
const router = new Router('/');
router.get({ path: '/', validate: false }, async (req, res) =>
// @ts-ignore. don't complain customField is not defined on Request type
res.ok({ customField: String(req.customField) })
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { customField: 'undefined' });
});
});
describe('Auth', () => {
const cookieOptions = {
name: 'sid',
encryptionKey: 'something_at_least_32_characters',
validate: () => true,
isSecure: false,
};
it('registers auth request interceptor only once', async () => {
const { registerAuth } = await server.setup(config);
const doRegister = () => registerAuth(() => null as any);
doRegister();
expect(doRegister).toThrowError('Auth interceptor was already registered');
});
it('may grant access to a resource', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
registerAuth((req, res, t) => t.authenticated());
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { content: 'ok' });
});
it('enables auth for a route by default if registerAuth has been called', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated());
registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: true });
expect(authenticate).toHaveBeenCalledTimes(1);
});
test('supports disabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false, options: { authRequired: false } }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn();
registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: false });
expect(authenticate).toHaveBeenCalledTimes(0);
});
test('supports enabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false, options: { authRequired: true } }, (req, res) =>
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest.fn().mockImplementation((req, res, t) => t.authenticated({}));
await registerAuth(authenticate);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { authRequired: true });
expect(authenticate).toHaveBeenCalledTimes(1);
});
it('supports rejecting a request from an unauthenticated user', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
registerAuth((req, res) => res.unauthorized());
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(401);
});
it('supports redirecting', async () => {
const redirectTo = '/redirect-url';
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
registerAuth((req, res) =>
res.redirected(undefined, {
headers: {
location: redirectTo,
},
})
);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(302);
expect(response.header.location).toBe(redirectTo);
});
it(`doesn't expose internal error details`, async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
registerRouter(router);
registerAuth((req, t) => {
throw new Error('reason');
});
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: reason],
],
]
`);
});
it('allows manipulating cookies via cookie session storage', async () => {
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
const {
createCookieSessionStorageFactory,
registerAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
const sessionStorageFactory = await createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
registerAuth((req, res, toolkit) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return toolkit.authenticated({ state: user });
});
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200, { content: 'ok' });
expect(response.header['set-cookie']).toBeDefined();
const cookies = response.header['set-cookie'];
expect(cookies).toHaveLength(1);
const sessionCookie = request.cookie(cookies[0]);
if (!sessionCookie) {
throw new Error('session cookie expected to be defined');
}
expect(sessionCookie).toBeDefined();
expect(sessionCookie.key).toBe('sid');
expect(sessionCookie.value).toBeDefined();
expect(sessionCookie.path).toBe('/');
expect(sessionCookie.httpOnly).toBe(true);
});
it('allows manipulating cookies from route handler', async () => {
const {
createCookieSessionStorageFactory,
registerAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
const sessionStorageFactory = await createCookieSessionStorageFactory<StorageData>(
cookieOptions
);
registerAuth((req, res, toolkit) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return toolkit.authenticated();
});
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => res.ok({ content: 'ok' }));
router.get({ path: '/with-cookie', validate: false }, (req, res) => {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.clear();
return res.ok({ content: 'ok' });
});
registerRouter(router);
await server.start();
const responseToSetCookie = await supertest(innerServer.listener)
.get('/')
.expect(200);
expect(responseToSetCookie.header['set-cookie']).toBeDefined();
const responseToResetCookie = await supertest(innerServer.listener)
.get('/with-cookie')
.expect(200);
expect(responseToResetCookie.header['set-cookie']).toEqual([
'sid=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/',
]);
});
it.skip('is the only place with access to the authorization header', async () => {
const token = 'Basic: user:password';
const {
registerAuth,
registerOnPreAuth,
registerOnPostAuth,
registerRouter,
server: innerServer,
} = await server.setup(config);
let fromRegisterOnPreAuth;
await registerOnPreAuth((req, res, toolkit) => {
fromRegisterOnPreAuth = req.headers.authorization;
return toolkit.next();
});
let fromRegisterAuth;
registerAuth((req, res, toolkit) => {
fromRegisterAuth = req.headers.authorization;
return toolkit.authenticated();
});
let fromRegisterOnPostAuth;
await registerOnPostAuth((req, res, toolkit) => {
fromRegisterOnPostAuth = req.headers.authorization;
return toolkit.next();
});
let fromRouteHandler;
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => {
fromRouteHandler = req.headers.authorization;
return res.ok({ content: 'ok' });
});
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.set('Authorization', token)
.expect(200);
expect(fromRegisterOnPreAuth).toEqual({});
expect(fromRegisterAuth).toEqual({ authorization: token });
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);
registerAuth((req, res, toolkit) => {
return toolkit.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);
registerAuth((req, res, toolkit) => {
return toolkit.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']);
});
it('logs warning if Auth Security Header rewrites response header for success response', async () => {
const authResponseHeader = {
'www-authenticate': 'from auth interceptor',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
registerAuth((req, res, toolkit) => {
return toolkit.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) =>
res.ok(
{},
{
headers: {
'www-authenticate': 'from handler',
},
}
)
);
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(200);
expect(response.header['www-authenticate']).toBe('from auth interceptor');
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Server rewrites a response header [www-authenticate].",
],
]
`);
});
it('logs warning if Auth Security Header rewrites response header for error response', async () => {
const authResponseHeader = {
'www-authenticate': 'from auth interceptor',
};
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
registerAuth((req, res, toolkit) => {
return toolkit.authenticated({ responseHeaders: authResponseHeader });
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) =>
res.badRequest('reason', {
headers: {
'www-authenticate': 'from handler',
},
})
);
registerRouter(router);
await server.start();
const response = await supertest(innerServer.listener)
.get('/')
.expect(400);
expect(response.header['www-authenticate']).toBe('from auth interceptor');
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Server rewrites a response header [www-authenticate].",
],
]
`);
});
it('supports redirection from the interceptor', async () => {
const router = new Router('/');
const redirectUrl = '/redirectUrl';
router.get({ path: '/initial', validate: false }, (req, res) => res.ok('initial'));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) =>
res.redirected(undefined, {
headers: {
location: redirectUrl,
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/initial')
.expect(302);
expect(result.header.location).toBe(redirectUrl);
});
it('supports rejecting request and adjusting response headers', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) =>
res.unauthorized('not found error', {
headers: {
'www-authenticate': 'challenge',
},
})
);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(401);
expect(result.header['www-authenticate']).toBe('challenge');
});
it("doesn't expose error details if interceptor throws", async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok(undefined));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) => {
throw new Error('reason');
});
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: reason],
],
]
`);
});
it('returns internal error if interceptor returns unexpected result', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok('ok'));
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerRouter(router);
registerOnPostAuth((req, res, t) => ({} as any));
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: [object Object].],
],
]
`);
});
it(`doesn't share request object between interceptors`, async () => {
const { registerRouter, registerOnPostAuth, server: innerServer } = await server.setup(config);
registerOnPostAuth((req, res, t) => {
// @ts-ignore. don't complain customField is not defined on Request type
req.customField = { value: 42 };
return t.next();
});
registerOnPostAuth((req, res, t) => {
// @ts-ignore don't complain customField is not defined on Request type
if (typeof req.customField !== 'undefined') {
throw new Error('Request object was mutated');
}
return t.next();
});
const router = new Router('/');
router.get({ path: '/', validate: false }, async (req, res) =>
// @ts-ignore. don't complain customField is not defined on Request type
res.ok({ customField: String(req.customField) })
);
registerRouter(router);
await server.start();
await supertest(innerServer.listener)
.get('/')
.expect(200, { customField: 'undefined' });
});
});

View file

@ -63,7 +63,7 @@ describe('Handler', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -88,7 +88,7 @@ describe('Handler', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -111,7 +111,7 @@ describe('Handler', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -146,7 +146,9 @@ describe('Handler', () => {
.expect(400);
expect(result.body).toEqual({
error: '[request query.page]: expected value of type [number] but got [string]',
error: 'Bad Request',
message: '[request query.page]: expected value of type [number] but got [string]',
statusCode: 400,
});
});
});
@ -531,7 +533,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -559,7 +561,11 @@ describe('Response factory', () => {
.get('/')
.expect(400);
expect(result.body).toEqual({ error: 'some message' });
expect(result.body).toEqual({
error: 'Bad Request',
message: 'some message',
statusCode: 400,
});
});
it('400 Bad request with default message', async () => {
@ -577,7 +583,11 @@ describe('Response factory', () => {
.get('/')
.expect(400);
expect(result.body).toEqual({ error: 'Bad Request' });
expect(result.body).toEqual({
error: 'Bad Request',
message: 'Bad Request',
statusCode: 400,
});
});
it('400 Bad request with additional data', async () => {
@ -596,10 +606,12 @@ describe('Response factory', () => {
.expect(400);
expect(result.body).toEqual({
error: 'some message',
error: 'Bad Request',
message: 'some message',
meta: {
data: ['good', 'bad'],
},
statusCode: 400,
});
});
@ -623,7 +635,7 @@ describe('Response factory', () => {
.get('/')
.expect(401);
expect(result.body.error).toBe('no access');
expect(result.body.message).toBe('no access');
expect(result.header['www-authenticate']).toBe('challenge');
});
@ -642,7 +654,7 @@ describe('Response factory', () => {
.get('/')
.expect(401);
expect(result.body.error).toBe('Unauthorized');
expect(result.body.message).toBe('Unauthorized');
});
it('403 Forbidden', async () => {
@ -661,7 +673,7 @@ describe('Response factory', () => {
.get('/')
.expect(403);
expect(result.body.error).toBe('reason');
expect(result.body.message).toBe('reason');
});
it('403 Forbidden with default message', async () => {
@ -679,7 +691,7 @@ describe('Response factory', () => {
.get('/')
.expect(403);
expect(result.body.error).toBe('Forbidden');
expect(result.body.message).toBe('Forbidden');
});
it('404 Not Found', async () => {
@ -698,7 +710,7 @@ describe('Response factory', () => {
.get('/')
.expect(404);
expect(result.body.error).toBe('file is not found');
expect(result.body.message).toBe('file is not found');
});
it('404 Not Found with default message', async () => {
@ -716,7 +728,7 @@ describe('Response factory', () => {
.get('/')
.expect(404);
expect(result.body.error).toBe('Not Found');
expect(result.body.message).toBe('Not Found');
});
it('409 Conflict', async () => {
@ -735,7 +747,7 @@ describe('Response factory', () => {
.get('/')
.expect(409);
expect(result.body.error).toBe('stale version');
expect(result.body.message).toBe('stale version');
});
it('409 Conflict with default message', async () => {
@ -753,7 +765,116 @@ describe('Response factory', () => {
.get('/')
.expect(409);
expect(result.body.error).toBe('Conflict');
expect(result.body.message).toBe('Conflict');
});
it('Custom error response', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => {
const error = new Error('some message');
return res.customError(error, {
statusCode: 418,
});
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(418);
expect(result.body).toEqual({
error: "I'm a teapot",
message: 'some message',
statusCode: 418,
});
});
it('Custom error response for server error', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => {
const error = new Error('some message');
return res.customError(error, {
statusCode: 500,
});
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body).toEqual({
error: 'Internal Server Error',
message: 'some message',
statusCode: 500,
});
});
it('Custom error response for Boom server error', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => {
const error = new Error('some message');
return res.customError(Boom.boomify(error), {
statusCode: 500,
});
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body).toEqual({
error: 'Internal Server Error',
message: 'some message',
statusCode: 500,
});
});
it('Custom error response requires error status code', async () => {
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => {
const error = new Error('some message');
return res.customError(error, {
statusCode: 200,
});
});
const { registerRouter, server: innerServer } = await server.setup(config);
registerRouter(router);
await server.start();
const result = await supertest(innerServer.listener)
.get('/')
.expect(500);
expect(result.body).toEqual({
error: 'Internal Server Error',
message: 'An internal server error occurred.',
statusCode: 500,
});
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
[Error: Unexpected Http status code. Expected from 400 to 599, but given: 200],
],
]
`);
});
});
@ -849,7 +970,7 @@ describe('Response factory', () => {
.get('/')
.expect(401);
expect(result.body.error).toBe('unauthorized');
expect(result.body.message).toBe('unauthorized');
});
it('creates error response with additional data', async () => {
@ -876,8 +997,10 @@ describe('Response factory', () => {
.expect(401);
expect(result.body).toEqual({
error: 'unauthorized',
error: 'Unauthorized',
message: 'unauthorized',
meta: { errorCode: 'K401' },
statusCode: 401,
});
});
@ -905,8 +1028,10 @@ describe('Response factory', () => {
.expect(401);
expect(result.body).toEqual({
error: 'unauthorized',
error: 'Unauthorized',
message: 'unauthorized',
meta: { errorCode: 'K401' },
statusCode: 401,
});
});
@ -928,7 +1053,7 @@ describe('Response factory', () => {
.get('/')
.expect(401);
expect(result.body.error).toBe('Unauthorized');
expect(result.body.message).toBe('Unauthorized');
});
it("Doesn't log details of created 500 Server error response", async () => {
@ -948,7 +1073,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('reason');
expect(result.body.message).toBe('reason');
expect(loggingServiceMock.collect(logger).error).toHaveLength(0);
});
@ -972,7 +1097,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -999,7 +1124,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -1025,7 +1150,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [
@ -1051,7 +1176,7 @@ describe('Response factory', () => {
.get('/')
.expect(500);
expect(result.body.error).toBe('An internal server error occurred.');
expect(result.body.message).toBe('An internal server error occurred.');
expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(`
Array [
Array [

View file

@ -1,110 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { adoptToHapiAuthFormat } from './auth';
import { httpServerMock } from '../http_server.mocks';
describe('adoptToHapiAuthFormat', () => {
it('allows to associate arbitrary data with an incoming request', async () => {
const authData = {
state: { foo: 'bar' },
requestHeaders: { authorization: 'baz' },
};
const authenticatedMock = jest.fn();
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(authData), onSuccessMock);
await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
authenticated: authenticatedMock,
})
);
expect(authenticatedMock).toBeCalledTimes(1);
expect(authenticatedMock).toBeCalledWith({ credentials: authData.state });
expect(onSuccessMock).toBeCalledTimes(1);
const [[, onSuccessData]] = onSuccessMock.mock.calls;
expect(onSuccessData).toEqual(authData);
});
it('Should allow redirecting to specified url', async () => {
const redirectUrl = '/docs';
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl), onSuccessMock);
const takeoverSymbol = {};
const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol }));
const result = await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
redirect: redirectMock,
})
);
expect(redirectMock).toBeCalledWith(redirectUrl);
expect(result).toBe(takeoverSymbol);
expect(onSuccessMock).not.toHaveBeenCalled();
});
it('Should allow to specify statusCode and message for Boom error', async () => {
const onSuccessMock = jest.fn();
const onAuth = adoptToHapiAuthFormat(
(req, t) => t.rejected(new Error('not found'), { statusCode: 404 }),
onSuccessMock
);
const result = (await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('not found');
expect(result.output.statusCode).toBe(404);
expect(onSuccessMock).not.toHaveBeenCalled();
});
it('Should return Boom.internal error error if interceptor throws', async () => {
const onAuth = adoptToHapiAuthFormat((req, t) => {
throw new Error('unknown error');
});
const result = (await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('unknown error');
expect(result.output.statusCode).toBe(500);
});
it('Should return Boom.internal error if interceptor returns unexpected result', async () => {
const onAuth = adoptToHapiAuthFormat(async (req, t) => undefined as any);
const result = (await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe(
'Unexpected result from Authenticate. Expected AuthResult, but given: undefined.'
);
expect(result.output.statusCode).toBe(500);
});
});

View file

@ -16,33 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { noop } from 'lodash';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { KibanaRequest } from '../router';
import { Logger } from '../../logging';
import {
HapiResponseAdapter,
KibanaRequest,
KibanaResponse,
lifecycleResponseFactory,
LifecycleResponseFactory,
} from '../router';
enum ResultType {
authenticated = 'authenticated',
redirected = 'redirected',
rejected = 'rejected',
}
interface Authenticated extends AuthResultParams {
type: ResultType.authenticated;
}
interface Redirected {
type: ResultType.redirected;
url: string;
}
interface Rejected {
type: ResultType.rejected;
error: Error;
statusCode?: number;
}
type AuthResult = Authenticated | Rejected | Redirected;
type AuthResult = Authenticated;
const authResult = {
authenticated(data: Partial<AuthResultParams> = {}): AuthResult {
@ -53,28 +45,8 @@ const authResult = {
responseHeaders: data.responseHeaders,
};
},
redirected(url: string): AuthResult {
return { type: ResultType.redirected, url };
},
rejected(error: Error, options: { statusCode?: number } = {}): AuthResult {
return { type: ResultType.rejected, error, statusCode: options.statusCode };
},
isValid(candidate: any): candidate is AuthResult {
return (
candidate &&
(candidate.type === ResultType.authenticated ||
candidate.type === ResultType.rejected ||
candidate.type === ResultType.redirected)
);
},
isAuthenticated(result: AuthResult): result is Authenticated {
return result.type === ResultType.authenticated;
},
isRedirected(result: AuthResult): result is Redirected {
return result.type === ResultType.redirected;
},
isRejected(result: AuthResult): result is Rejected {
return result.type === ResultType.rejected;
return result && result.type === ResultType.authenticated;
},
};
@ -113,55 +85,53 @@ export interface AuthResultParams {
export interface AuthToolkit {
/** Authentication is successful with given credentials, allow request to pass through */
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. */
rejected: (error: Error, options?: { statusCode?: number }) => AuthResult;
}
const toolkit: AuthToolkit = {
authenticated: authResult.authenticated,
redirected: authResult.redirected,
rejected: authResult.rejected,
};
/** @public */
export type AuthenticationHandler = (
request: KibanaRequest,
t: AuthToolkit
) => AuthResult | Promise<AuthResult>;
response: LifecycleResponseFactory,
toolkit: AuthToolkit
) => AuthResult | KibanaResponse | Promise<AuthResult | KibanaResponse>;
/** @public */
export function adoptToHapiAuthFormat(
fn: AuthenticationHandler,
onSuccess: (req: Request, data: AuthResultParams) => void = noop
log: Logger,
onSuccess: (req: Request, data: AuthResultParams) => void = () => undefined
) {
return async function interceptAuth(
req: Request,
h: ResponseToolkit
request: Request,
responseToolkit: ResponseToolkit
): Promise<Lifecycle.ReturnValue> {
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
const result = await fn(KibanaRequest.from(req, undefined, false), toolkit);
if (!authResult.isValid(result)) {
throw new Error(
`Unexpected result from Authenticate. Expected AuthResult, but given: ${result}.`
);
const result = await fn(
KibanaRequest.from(request, undefined, false),
lifecycleResponseFactory,
toolkit
);
if (result instanceof KibanaResponse) {
return hapiResponseAdapter.handle(result);
}
if (authResult.isAuthenticated(result)) {
onSuccess(req, {
onSuccess(request, {
state: result.state,
requestHeaders: result.requestHeaders,
responseHeaders: result.responseHeaders,
});
return h.authenticated({ credentials: result.state || {} });
return responseToolkit.authenticated({ credentials: result.state || {} });
}
if (authResult.isRedirected(result)) {
return h.redirect(result.url).takeover();
}
const { error, statusCode } = result;
return Boom.boomify(error, { statusCode });
throw new Error(
`Unexpected result from Authenticate. Expected AuthResult or KibanaResponse, but given: ${result}.`
);
} catch (error) {
return Boom.internal(error.message, { statusCode: 500 });
log.error(error);
return hapiResponseAdapter.toInternalError();
}
};
}

View file

@ -1,95 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { adoptToHapiOnPostAuthFormat } from './on_post_auth';
import { httpServerMock } from '../http_server.mocks';
describe('adoptToHapiOnPostAuthFormat', () => {
it('Should allow passing request to the next handler', async () => {
const continueSymbol = Symbol();
const onPostAuth = adoptToHapiOnPostAuthFormat((req, t) => t.next());
const result = await onPostAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
['continue']: continueSymbol,
})
);
expect(result).toBe(continueSymbol);
});
it('Should support redirecting to specified url', async () => {
const redirectUrl = '/docs';
const onPostAuth = adoptToHapiOnPostAuthFormat((req, t) => t.redirected(redirectUrl));
const takeoverSymbol = {};
const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol }));
const result = await onPostAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
redirect: redirectMock,
})
);
expect(redirectMock).toBeCalledWith(redirectUrl);
expect(result).toBe(takeoverSymbol);
});
it('Should support specifying statusCode and message for Boom error', async () => {
const onPostAuth = adoptToHapiOnPostAuthFormat((req, t) => {
return t.rejected(new Error('unexpected result'), { statusCode: 501 });
});
const result = (await onPostAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('unexpected result');
expect(result.output.statusCode).toBe(501);
});
it('Should return Boom.internal error if interceptor throws', async () => {
const onPostAuth = adoptToHapiOnPostAuthFormat((req, t) => {
throw new Error('unknown error');
});
const result = (await onPostAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('unknown error');
expect(result.output.statusCode).toBe(500);
});
it('Should return Boom.internal error if interceptor returns unexpected result', async () => {
const onPostAuth = adoptToHapiOnPostAuthFormat((req, toolkit) => undefined as any);
const result = (await onPostAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toMatchInlineSnapshot(
`"Unexpected result from OnPostAuth. Expected OnPostAuthResult, but given: undefined."`
);
expect(result.output.statusCode).toBe(500);
});
});

View file

@ -17,59 +17,32 @@
* under the License.
*/
import Boom from 'boom';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { KibanaRequest } from '../router';
import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi';
import { Logger } from '../../logging';
import {
HapiResponseAdapter,
KibanaRequest,
KibanaResponse,
lifecycleResponseFactory,
LifecycleResponseFactory,
} from '../router';
enum ResultType {
next = 'next',
redirected = 'redirected',
rejected = 'rejected',
}
interface Next {
type: ResultType.next;
}
interface Redirected {
type: ResultType.redirected;
url: string;
}
interface Rejected {
type: ResultType.rejected;
error: Error;
statusCode?: number;
}
type OnPostAuthResult = Next | Rejected | Redirected;
type OnPostAuthResult = Next;
const postAuthResult = {
next(): OnPostAuthResult {
return { type: ResultType.next };
},
redirected(url: string): OnPostAuthResult {
return { type: ResultType.redirected, url };
},
rejected(error: Error, options: { statusCode?: number } = {}): OnPostAuthResult {
return { type: ResultType.rejected, error, statusCode: options.statusCode };
},
isValid(candidate: any): candidate is OnPostAuthResult {
return (
candidate &&
(candidate.type === ResultType.next ||
candidate.type === ResultType.rejected ||
candidate.type === ResultType.redirected)
);
},
isNext(result: OnPostAuthResult): result is Next {
return result.type === ResultType.next;
},
isRedirected(result: OnPostAuthResult): result is Redirected {
return result.type === ResultType.redirected;
},
isRejected(result: OnPostAuthResult): result is Rejected {
return result.type === ResultType.rejected;
return result && result.type === ResultType.next;
},
};
@ -80,51 +53,46 @@ const postAuthResult = {
export interface OnPostAuthToolkit {
/** To pass request to the next handler */
next: () => OnPostAuthResult;
/** To interrupt request handling and redirect to a configured url */
redirected: (url: string) => OnPostAuthResult;
/** Fail the request with specified error. */
rejected: (error: Error, options?: { statusCode?: number }) => OnPostAuthResult;
}
/** @public */
export type OnPostAuthHandler<Params = any, Query = any, Body = any> = (
request: KibanaRequest<Params, Query, Body>,
t: OnPostAuthToolkit
) => OnPostAuthResult | Promise<OnPostAuthResult>;
export type OnPostAuthHandler = (
request: KibanaRequest,
response: LifecycleResponseFactory,
toolkit: OnPostAuthToolkit
) => OnPostAuthResult | KibanaResponse | Promise<OnPostAuthResult | KibanaResponse>;
const toolkit: OnPostAuthToolkit = {
next: postAuthResult.next,
redirected: postAuthResult.redirected,
rejected: postAuthResult.rejected,
};
/**
* @public
* Adopt custom request interceptor to Hapi lifecycle system.
* @param fn - an extension point allowing to perform custom logic for
* incoming HTTP requests.
*/
export function adoptToHapiOnPostAuthFormat(fn: OnPostAuthHandler) {
export function adoptToHapiOnPostAuthFormat(fn: OnPostAuthHandler, log: Logger) {
return async function interceptRequest(
request: Request,
h: ResponseToolkit
responseToolkit: HapiResponseToolkit
): Promise<Lifecycle.ReturnValue> {
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
const result = await fn(KibanaRequest.from(request), toolkit);
if (!postAuthResult.isValid(result)) {
throw new Error(
`Unexpected result from OnPostAuth. Expected OnPostAuthResult, but given: ${result}.`
);
const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit);
if (result instanceof KibanaResponse) {
return hapiResponseAdapter.handle(result);
}
if (postAuthResult.isNext(result)) {
return h.continue;
return responseToolkit.continue;
}
if (postAuthResult.isRedirected(result)) {
return h.redirect(result.url).takeover();
}
const { error, statusCode } = result;
return Boom.boomify(error, { statusCode });
throw new Error(
`Unexpected result from OnPostAuth. Expected OnPostAuthResult or KibanaResponse, but given: ${result}.`
);
} catch (error) {
return Boom.internal(error.message, { statusCode: 500 });
log.error(error);
return hapiResponseAdapter.toInternalError();
}
};
}

View file

@ -1,115 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { adoptToHapiOnPreAuthFormat } from './on_pre_auth';
import { httpServerMock } from '../http_server.mocks';
describe('adoptToHapiOnPreAuthFormat', () => {
it('Should allow passing request to the next handler', async () => {
const continueSymbol = Symbol();
const onPreAuth = adoptToHapiOnPreAuthFormat((req, t) => t.next());
const result = await onPreAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
['continue']: continueSymbol,
})
);
expect(result).toBe(continueSymbol);
});
it('Should support redirecting to specified url', async () => {
const redirectUrl = '/docs';
const onPreAuth = adoptToHapiOnPreAuthFormat((req, t) => t.redirected(redirectUrl));
const takeoverSymbol = {};
const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol }));
const result = await onPreAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
redirect: redirectMock,
})
);
expect(redirectMock).toBeCalledWith(redirectUrl);
expect(result).toBe(takeoverSymbol);
});
it('Should support request forwarding to specified url', async () => {
const redirectUrl = '/docs';
const onPreAuth = adoptToHapiOnPreAuthFormat((req, t) =>
t.redirected(redirectUrl, { forward: true })
);
const continueSymbol = Symbol();
const setUrl = jest.fn();
const mockedRequest = httpServerMock.createRawRequest({ setUrl });
const result = await onPreAuth(
mockedRequest,
httpServerMock.createRawResponseToolkit({
['continue']: continueSymbol,
})
);
expect(setUrl).toBeCalledWith(redirectUrl);
expect(mockedRequest.raw.req.url).toBe(redirectUrl);
expect(result).toBe(continueSymbol);
});
it('Should support specifying statusCode and message for Boom error', async () => {
const onPreAuth = adoptToHapiOnPreAuthFormat((req, t) => {
return t.rejected(new Error('unexpected result'), { statusCode: 501 });
});
const result = (await onPreAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('unexpected result');
expect(result.output.statusCode).toBe(501);
});
it('Should return Boom.internal error if interceptor throws', async () => {
const onPreAuth = adoptToHapiOnPreAuthFormat((req, t) => {
throw new Error('unknown error');
});
const result = (await onPreAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toBe('unknown error');
expect(result.output.statusCode).toBe(500);
});
it('Should return Boom.internal error if interceptor returns unexpected result', async () => {
const onPreAuth = adoptToHapiOnPreAuthFormat((req, toolkit) => undefined as any);
const result = (await onPreAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
)) as Boom;
expect(result).toBeInstanceOf(Boom);
expect(result.message).toMatchInlineSnapshot(
`"Unexpected result from OnPreAuth. Expected OnPreAuthResult, but given: undefined."`
);
expect(result.output.statusCode).toBe(500);
});
});

View file

@ -17,60 +17,44 @@
* under the License.
*/
import Boom from 'boom';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { KibanaRequest } from '../router';
import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi';
import { Logger } from '../../logging';
import {
HapiResponseAdapter,
KibanaRequest,
KibanaResponse,
lifecycleResponseFactory,
LifecycleResponseFactory,
} from '../router';
enum ResultType {
next = 'next',
redirected = 'redirected',
rejected = 'rejected',
rewriteUrl = 'rewriteUrl',
}
interface Next {
type: ResultType.next;
}
interface Redirected {
type: ResultType.redirected;
interface RewriteUrl {
type: ResultType.rewriteUrl;
url: string;
forward?: boolean;
}
interface Rejected {
type: ResultType.rejected;
error: Error;
statusCode?: number;
}
type OnPreAuthResult = Next | Rejected | Redirected;
type OnPreAuthResult = Next | RewriteUrl;
const preAuthResult = {
next(): OnPreAuthResult {
return { type: ResultType.next };
},
redirected(url: string, options: { forward?: boolean } = {}): OnPreAuthResult {
return { type: ResultType.redirected, url, forward: options.forward };
},
rejected(error: Error, options: { statusCode?: number } = {}): OnPreAuthResult {
return { type: ResultType.rejected, error, statusCode: options.statusCode };
},
isValid(candidate: any): candidate is OnPreAuthResult {
return (
candidate &&
(candidate.type === ResultType.next ||
candidate.type === ResultType.rejected ||
candidate.type === ResultType.redirected)
);
rewriteUrl(url: string): OnPreAuthResult {
return { type: ResultType.rewriteUrl, url };
},
isNext(result: OnPreAuthResult): result is Next {
return result.type === ResultType.next;
return result && result.type === ResultType.next;
},
isRedirected(result: OnPreAuthResult): result is Redirected {
return result.type === ResultType.redirected;
},
isRejected(result: OnPreAuthResult): result is Rejected {
return result.type === ResultType.rejected;
isRewriteUrl(result: OnPreAuthResult): result is RewriteUrl {
return result && result.type === ResultType.rewriteUrl;
},
};
@ -81,26 +65,21 @@ const preAuthResult = {
export interface OnPreAuthToolkit {
/** To pass request to the next handler */
next: () => OnPreAuthResult;
/**
* To interrupt request handling and redirect to a configured url.
* If "options.forwarded" = true, request will be forwarded to another url right on the server.
* */
redirected: (url: string, options?: { forward: boolean }) => OnPreAuthResult;
/** Fail the request with specified error. */
rejected: (error: Error, options?: { statusCode?: number }) => OnPreAuthResult;
/** Rewrite requested resources url before is was authenticated and routed to a handler */
rewriteUrl: (url: string) => OnPreAuthResult;
}
const toolkit: OnPreAuthToolkit = {
next: preAuthResult.next,
redirected: preAuthResult.redirected,
rejected: preAuthResult.rejected,
rewriteUrl: preAuthResult.rewriteUrl,
};
/** @public */
export type OnPreAuthHandler<Params = any, Query = any, Body = any> = (
request: KibanaRequest<Params, Query, Body>,
t: OnPreAuthToolkit
) => OnPreAuthResult | Promise<OnPreAuthResult>;
export type OnPreAuthHandler = (
request: KibanaRequest,
response: LifecycleResponseFactory,
toolkit: OnPreAuthToolkit
) => OnPreAuthResult | KibanaResponse | Promise<OnPreAuthResult | KibanaResponse>;
/**
* @public
@ -108,38 +87,36 @@ export type OnPreAuthHandler<Params = any, Query = any, Body = any> = (
* @param fn - an extension point allowing to perform custom logic for
* incoming HTTP requests.
*/
export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler) {
export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) {
return async function interceptPreAuthRequest(
request: Request,
h: ResponseToolkit
responseToolkit: HapiResponseToolkit
): Promise<Lifecycle.ReturnValue> {
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
const result = await fn(KibanaRequest.from(request), toolkit);
if (!preAuthResult.isValid(result)) {
throw new Error(
`Unexpected result from OnPreAuth. Expected OnPreAuthResult, but given: ${result}.`
);
const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit);
if (result instanceof KibanaResponse) {
return hapiResponseAdapter.handle(result);
}
if (preAuthResult.isNext(result)) {
return h.continue;
return responseToolkit.continue;
}
if (preAuthResult.isRedirected(result)) {
const { url, forward } = result;
if (forward) {
request.setUrl(url);
// We should update raw request as well since it can be proxied to the old platform
request.raw.req.url = url;
return h.continue;
}
return h.redirect(url).takeover();
if (preAuthResult.isRewriteUrl(result)) {
const { url } = result;
request.setUrl(url);
// We should update raw request as well since it can be proxied to the old platform
request.raw.req.url = url;
return responseToolkit.continue;
}
const { error, statusCode } = result;
return Boom.boomify(error, { statusCode });
throw new Error(
`Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: ${result}.`
);
} catch (error) {
return Boom.internal(error.message, { statusCode: 500 });
log.error(error);
return hapiResponseAdapter.toInternalError();
}
};
}

View file

@ -27,6 +27,7 @@ export {
ensureRawRequest,
} from './request';
export { RouteMethod, RouteConfig, RouteConfigOptions } from './route';
export { HapiResponseAdapter } from './response_adapter';
export {
CustomHttpResponseOptions,
HttpResponseOptions,
@ -34,6 +35,9 @@ export {
RedirectResponseOptions,
ResponseError,
ResponseErrorMeta,
KibanaResponse,
kibanaResponseFactory,
KibanaResponseFactory,
lifecycleResponseFactory,
LifecycleResponseFactory,
} from './response';

View file

@ -44,7 +44,7 @@ export type ResponseError =
* A response data object, expected to returned as a result of {@link RequestHandler} execution
* @internal
*/
export class KibanaResponse<T extends HttpResponsePayload | ResponseError> {
export class KibanaResponse<T extends HttpResponsePayload | ResponseError = any> {
constructor(
readonly status: number,
readonly payload?: T,
@ -85,6 +85,118 @@ export type RedirectResponseOptions = HttpResponseOptions & {
};
};
const successResponseFactory = {
/**
* The request has succeeded.
* Status code: `200`.
* @param payload - {@link HttpResponsePayload} payload to send to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
ok: (payload: HttpResponsePayload, options: HttpResponseOptions = {}) =>
new KibanaResponse(200, payload, options),
/**
* The request has been accepted for processing.
* Status code: `202`.
* @param payload - {@link HttpResponsePayload} payload to send to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
accepted: (payload?: HttpResponsePayload, options: HttpResponseOptions = {}) =>
new KibanaResponse(202, payload, options),
/**
* The server has successfully fulfilled the request and that there is no additional content to send in the response payload body.
* Status code: `204`.
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
noContent: (options: HttpResponseOptions = {}) => new KibanaResponse(204, undefined, options),
};
const redirectionResponseFactory = {
/**
* Redirect to a different URI.
* Status code: `302`.
* @param payload - payload to send to the client
* @param options - {@link RedirectResponseOptions} configures HTTP response parameters.
* Expects `location` header to be set.
*/
redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) =>
new KibanaResponse(302, payload, options),
};
const errorResponseFactory = {
/**
* The server cannot process the request due to something that is perceived to be a client error.
* Status code: `400`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
badRequest: (error: ResponseError = 'Bad Request', options: HttpResponseOptions = {}) =>
new KibanaResponse(400, error, options),
/**
* The request cannot be applied because it lacks valid authentication credentials for the target resource.
* Status code: `401`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
unauthorized: (error: ResponseError = 'Unauthorized', options: HttpResponseOptions = {}) =>
new KibanaResponse(401, error, options),
/**
* Server cannot grant access to a resource.
* Status code: `403`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
forbidden: (error: ResponseError = 'Forbidden', options: HttpResponseOptions = {}) =>
new KibanaResponse(403, error, options),
/**
* Server cannot find a current representation for the target resource.
* Status code: `404`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
notFound: (error: ResponseError = 'Not Found', options: HttpResponseOptions = {}) =>
new KibanaResponse(404, error, options),
/**
* The request could not be completed due to a conflict with the current state of the target resource.
* Status code: `409`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
conflict: (error: ResponseError = 'Conflict', options: HttpResponseOptions = {}) =>
new KibanaResponse(409, error, options),
// Server error
/**
* The server encountered an unexpected condition that prevented it from fulfilling the request.
* Status code: `500`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
internalError: (error: ResponseError = 'Internal Error', options: HttpResponseOptions = {}) =>
new KibanaResponse(500, error, options),
/**
* Creates an error response with defined status code and payload.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link CustomHttpResponseOptions} configures HTTP response parameters.
*/
customError: (error: ResponseError, options: CustomHttpResponseOptions) => {
if (!options || !options.statusCode) {
throw new Error(`options.statusCode is expected to be set. given options: ${options}`);
}
if (options.statusCode < 400 || options.statusCode >= 600) {
throw new Error(
`Unexpected Http status code. Expected from 400 to 599, but given: ${options.statusCode}`
);
}
return new KibanaResponse(options.statusCode, error, options);
},
};
/**
* Set of helpers used to create `KibanaResponse` to form HTTP response on an incoming request.
* Should be returned as a result of {@link RequestHandler} execution.
@ -178,32 +290,9 @@ export type RedirectResponseOptions = HttpResponseOptions & {
* @public
*/
export const kibanaResponseFactory = {
// Success
/**
* The request has succeeded.
* Status code: `200`.
* @param payload - {@link HttpResponsePayload} payload to send to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
ok: (payload: HttpResponsePayload, options: HttpResponseOptions = {}) =>
new KibanaResponse(200, payload, options),
/**
* The request has been accepted for processing.
* Status code: `202`.
* @param payload - {@link HttpResponsePayload} payload to send to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
accepted: (payload?: HttpResponsePayload, options: HttpResponseOptions = {}) =>
new KibanaResponse(202, payload, options),
/**
* The server has successfully fulfilled the request and that there is no additional content to send in the response payload body.
* Status code: `204`.
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
noContent: (options: HttpResponseOptions = {}) => new KibanaResponse(204, undefined, options),
...successResponseFactory,
...redirectionResponseFactory,
...errorResponseFactory,
/**
* Creates a response with defined status code and payload.
* @param payload - {@link HttpResponsePayload} payload to send to the client
@ -216,73 +305,11 @@ export const kibanaResponseFactory = {
const { statusCode: code, ...rest } = options;
return new KibanaResponse(code, payload, rest);
},
};
// Redirection
/**
* Redirect to a different URI.
* Status code: `302`.
* @param payload - payload to send to the client
* @param options - {@link RedirectResponseOptions} configures HTTP response parameters.
* Expects `location` header to be set.
*/
redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) =>
new KibanaResponse(302, payload, options),
// Client error
/**
* The server cannot process the request due to something that is perceived to be a client error.
* Status code: `400`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
badRequest: (error: ResponseError = 'Bad Request', options: HttpResponseOptions = {}) =>
new KibanaResponse(400, error, options),
/**
* The request cannot be applied because it lacks valid authentication credentials for the target resource.
* Status code: `401`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
unauthorized: (error: ResponseError = 'Unauthorized', options: HttpResponseOptions = {}) =>
new KibanaResponse(401, error, options),
/**
* Server cannot grant access to a resource.
* Status code: `403`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
forbidden: (error: ResponseError = 'Forbidden', options: HttpResponseOptions = {}) =>
new KibanaResponse(403, error, options),
/**
* Server cannot find a current representation for the target resource.
* Status code: `404`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
notFound: (error: ResponseError = 'Not Found', options: HttpResponseOptions = {}) =>
new KibanaResponse(404, error, options),
/**
* The request could not be completed due to a conflict with the current state of the target resource.
* Status code: `409`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
conflict: (error: ResponseError = 'Conflict', options: HttpResponseOptions = {}) =>
new KibanaResponse(409, error, options),
// Server error
/**
* The server encountered an unexpected condition that prevented it from fulfilling the request.
* Status code: `500`.
* @param error - {@link ResponseError} Error object containing message and other error details to pass to the client
* @param options - {@link HttpResponseOptions} configures HTTP response parameters.
*/
internal: (error: ResponseError = 'Internal Error', options: HttpResponseOptions = {}) =>
new KibanaResponse(500, error, options),
export const lifecycleResponseFactory = {
...redirectionResponseFactory,
...errorResponseFactory,
};
/**
@ -290,3 +317,9 @@ export const kibanaResponseFactory = {
* @public
*/
export type KibanaResponseFactory = typeof kibanaResponseFactory;
/**
* Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client.
* @public
*/
export type LifecycleResponseFactory = typeof lifecycleResponseFactory;

View file

@ -18,13 +18,20 @@
*/
import { ResponseObject as HapiResponseObject, ResponseToolkit as HapiResponseToolkit } from 'hapi';
import typeDetect from 'type-detect';
import Boom from 'boom';
import { HttpResponsePayload, KibanaResponse, ResponseError } from './response';
import { HttpResponsePayload, KibanaResponse, ResponseError, ResponseErrorMeta } from './response';
declare module 'boom' {
interface Payload {
meta?: ResponseErrorMeta;
}
}
function setHeaders(response: HapiResponseObject, headers: Record<string, string | string[]> = {}) {
Object.entries(headers).forEach(([header, value]) => {
if (value !== undefined) {
// Hapi typings for header accept only string, although string[] is a valid value
// Hapi typings for header accept only strings, although string[] is a valid value
response.header(header, value as any);
}
});
@ -40,14 +47,22 @@ const statusHelpers = {
export class HapiResponseAdapter {
constructor(private readonly responseToolkit: HapiResponseToolkit) {}
public toBadRequest(message: string) {
return this.responseToolkit.response({ error: message }).code(400);
const error = Boom.badRequest();
error.output.payload.message = message;
return error;
}
public toInternalError() {
return this.responseToolkit.response({ error: 'An internal server error occurred.' }).code(500);
const error = new Boom('', {
statusCode: 500,
});
error.output.payload.message = 'An internal server error occurred.';
return error;
}
public handle(kibanaResponse: KibanaResponse<any>) {
public handle(kibanaResponse: KibanaResponse) {
if (!(kibanaResponse instanceof KibanaResponse)) {
throw new Error(
`Unexpected result from Route Handler. Expected KibanaResponse, but given: ${typeDetect(
@ -56,28 +71,30 @@ export class HapiResponseAdapter {
);
}
const response = this.toHapiResponse(kibanaResponse);
setHeaders(response, kibanaResponse.options.headers);
return response;
return this.toHapiResponse(kibanaResponse);
}
private toHapiResponse(kibanaResponse: KibanaResponse<any>) {
private toHapiResponse(kibanaResponse: KibanaResponse) {
if (statusHelpers.isError(kibanaResponse.status)) {
return this.toError(kibanaResponse);
}
if (statusHelpers.isSuccess(kibanaResponse.status)) {
return this.toSuccess(kibanaResponse);
}
if (statusHelpers.isRedirect(kibanaResponse.status)) {
return this.toRedirect(kibanaResponse);
}
if (statusHelpers.isError(kibanaResponse.status)) {
return this.toError(kibanaResponse);
}
throw new Error(
`Unexpected Http status code. Expected from 100 to 599, but given: ${kibanaResponse.status}.`
);
}
private toSuccess(kibanaResponse: KibanaResponse<HttpResponsePayload>) {
return this.responseToolkit.response(kibanaResponse.payload).code(kibanaResponse.status);
const response = this.responseToolkit
.response(kibanaResponse.payload)
.code(kibanaResponse.status);
setHeaders(response, kibanaResponse.options.headers);
return response;
}
private toRedirect(kibanaResponse: KibanaResponse<HttpResponsePayload>) {
@ -86,20 +103,33 @@ export class HapiResponseAdapter {
throw new Error("expected 'location' header to be set");
}
return this.responseToolkit
const response = this.responseToolkit
.response(kibanaResponse.payload)
.redirect(headers.location)
.code(kibanaResponse.status);
.code(kibanaResponse.status)
.takeover();
setHeaders(response, kibanaResponse.options.headers);
return response;
}
private toError(kibanaResponse: KibanaResponse<ResponseError>) {
const { payload } = kibanaResponse;
return this.responseToolkit
.response({
error: getErrorMessage(payload),
meta: getErrorMeta(payload),
})
.code(kibanaResponse.status);
// we use for BWC with Boom payload for error responses - {error: string, message: string, statusCode: string}
const error = new Boom('', {
statusCode: kibanaResponse.status,
});
error.output.payload.message = getErrorMessage(payload);
error.output.payload.meta = getErrorMeta(payload);
const headers = kibanaResponse.options.headers;
if (headers) {
// Hapi typings for header accept only strings, although string[] is a valid value
error.output.headers = headers as any;
}
return error;
}
}

View file

@ -19,6 +19,7 @@
import { ObjectType, TypeOf, Type } from '@kbn/config-schema';
import { Request, ResponseObject, ResponseToolkit } from 'hapi';
import Boom from 'boom';
import { Logger } from '../../logging';
import { KibanaRequest } from './request';
@ -30,7 +31,11 @@ interface RouterRoute {
method: RouteMethod;
path: string;
options: RouteConfigOptions;
handler: (req: Request, responseToolkit: ResponseToolkit, log: Logger) => Promise<ResponseObject>;
handler: (
request: Request,
responseToolkit: ResponseToolkit,
log: Logger
) => Promise<ResponseObject | Boom<any>>;
}
/**
@ -47,10 +52,8 @@ interface RouterRoute {
* @public
* */
export class Router {
public routes: Array<Readonly<RouterRoute>> = [];
/**
* @param path - a router path, set as the very first path segment for all registered routes.
*/
private routes: Array<Readonly<RouterRoute>> = [];
constructor(readonly path: string) {}
/**

View file

@ -78,6 +78,7 @@ export {
IsAuthenticated,
KibanaRequest,
KibanaRequestRoute,
LifecycleResponseFactory,
KnownHeaders,
LegacyRequest,
OnPreAuthHandler,

View file

@ -25,9 +25,10 @@ import { Url } from 'url';
export type APICaller = (endpoint: string, clientParams: Record<string, any>, options?: CallAPIOptions) => Promise<unknown>;
// Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export type AuthenticationHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit) => AuthResult | KibanaResponse | Promise<AuthResult | KibanaResponse>;
// @public
export type AuthHeaders = Record<string, string | string[]>;
@ -49,10 +50,6 @@ export enum AuthStatus {
// @public
export interface AuthToolkit {
authenticated: (data?: AuthResultParams) => AuthResult;
redirected: (url: string) => AuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
}) => AuthResult;
}
// Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts
@ -303,9 +300,6 @@ export type KibanaResponseFactory = typeof kibanaResponseFactory;
// @public
export const kibanaResponseFactory: {
ok: (payload: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
accepted: (payload?: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
noContent: (options?: HttpResponseOptions) => KibanaResponse<undefined>;
custom: (payload: string | Error | Record<string, any> | Buffer | Stream | {
message: string | Error;
meta?: ResponseErrorMeta | undefined;
@ -313,13 +307,17 @@ export const kibanaResponseFactory: {
message: string | Error;
meta?: ResponseErrorMeta | undefined;
}>;
redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
badRequest: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
unauthorized: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
forbidden: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
notFound: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
conflict: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
internal: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
internalError: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse<ResponseError>;
customError: (error: ResponseError, options: CustomHttpResponseOptions) => KibanaResponse<ResponseError>;
redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
ok: (payload: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
accepted: (payload?: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse<string | Record<string, any> | Buffer | Stream>;
noContent: (options?: HttpResponseOptions) => KibanaResponse<undefined>;
};
// Warning: (ae-forgotten-export) The symbol "KnownKeys" needs to be exported by the entry point index.d.ts
@ -331,6 +329,11 @@ export type KnownHeaders = KnownKeys<IncomingHttpHeaders>;
export interface LegacyRequest extends Request {
}
// Warning: (ae-forgotten-export) The symbol "lifecycleResponseFactory" needs to be exported by the entry point index.d.ts
//
// @public
export type LifecycleResponseFactory = typeof lifecycleResponseFactory;
// @public
export interface Logger {
debug(message: string, meta?: LogMeta): void;
@ -403,31 +406,22 @@ export interface LogRecord {
// Warning: (ae-forgotten-export) The symbol "OnPostAuthResult" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type OnPostAuthHandler<Params = any, Query = any, Body = any> = (request: KibanaRequest<Params, Query, Body>, t: OnPostAuthToolkit) => OnPostAuthResult | Promise<OnPostAuthResult>;
export type OnPostAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPostAuthToolkit) => OnPostAuthResult | KibanaResponse | Promise<OnPostAuthResult | KibanaResponse>;
// @public
export interface OnPostAuthToolkit {
next: () => OnPostAuthResult;
redirected: (url: string) => OnPostAuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnPostAuthResult;
}
// Warning: (ae-forgotten-export) The symbol "OnPreAuthResult" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type OnPreAuthHandler<Params = any, Query = any, Body = any> = (request: KibanaRequest<Params, Query, Body>, t: OnPreAuthToolkit) => OnPreAuthResult | Promise<OnPreAuthResult>;
export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit) => OnPreAuthResult | KibanaResponse | Promise<OnPreAuthResult | KibanaResponse>;
// @public
export interface OnPreAuthToolkit {
next: () => OnPreAuthResult;
redirected: (url: string, options?: {
forward: boolean;
}) => OnPreAuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnPreAuthResult;
rewriteUrl: (url: string) => OnPreAuthResult;
}
// @public
@ -549,16 +543,14 @@ export class Router {
constructor(path: string);
delete<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
get<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
// Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts
//
// @internal
getRoutes(): Readonly<RouterRoute>[];
// (undocumented)
readonly path: string;
post<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
put<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(route: RouteConfig<P, Q, B>, handler: RequestHandler<P, Q, B>): void;
// Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts
//
// (undocumented)
routes: Array<Readonly<RouterRoute>>;
}
// @public (undocumented)
@ -1029,7 +1021,6 @@ export interface SessionStorageFactory<T> {
// Warnings were encountered during analysis:
//
// src/core/server/http/router/response.ts:188:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/plugins_service.ts:39:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:162:10 - (ae-forgotten-export) The symbol "EnvironmentMode" needs to be exported by the entry point index.d.ts

View file

@ -17,6 +17,7 @@ import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_ser
import {
elasticsearchServiceMock,
httpServiceMock,
httpServerMock,
} from '../../../../../../../src/core/server/mocks';
import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server';
import { HttpServiceSetup } from 'src/core/server';
@ -184,17 +185,21 @@ describe('onPostAuthRequestInterceptor', () => {
httpMock.basePath.set = jest.fn().mockImplementation((req: any, newPath: string) => {
basePath = newPath;
});
const preAuthToolkit = httpServiceMock.createOnPreAuthToolkit();
preAuthToolkit.rewriteUrl.mockImplementation(url => {
path = url;
return null as any;
});
httpMock.registerOnPreAuth = jest.fn().mockImplementation(async handler => {
const preAuthRequest = {
path,
url: parse(path),
};
await handler(preAuthRequest, {
redirected: jest.fn().mockImplementation(url => {
path = url;
}),
next: jest.fn(),
});
await handler(
preAuthRequest,
httpServerMock.createLifecycleResponseFactory(),
preAuthToolkit
);
});
const service = new SpacesService(log, configFn().get('server.basePath'));

View file

@ -3,7 +3,12 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest, OnPreAuthToolkit, HttpServiceSetup } from 'src/core/server';
import {
KibanaRequest,
OnPreAuthToolkit,
LifecycleResponseFactory,
HttpServiceSetup,
} from 'src/core/server';
import { KibanaConfig } from 'src/legacy/server/kbn_server';
import { format } from 'url';
import { DEFAULT_SPACE_ID } from '../../../common/constants';
@ -19,6 +24,7 @@ export function initSpacesOnRequestInterceptor({ config, http }: OnRequestInterc
http.registerOnPreAuth(async function spacesOnPreAuthHandler(
request: KibanaRequest,
response: LifecycleResponseFactory,
toolkit: OnPreAuthToolkit
) {
const path = request.url.pathname;
@ -41,7 +47,7 @@ export function initSpacesOnRequestInterceptor({ config, http }: OnRequestInterc
};
});
return toolkit.redirected(newUrl, { forward: true });
return toolkit.rewriteUrl(newUrl);
}
return toolkit.next();

View file

@ -32,7 +32,6 @@ import {
} from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../common/model';
import { ConfigType, createConfig$ } from '../config';
import { getErrorStatusCode } from '../errors';
import { LegacyAPI } from '../plugin';
import { AuthenticationResult } from './authentication_result';
import { setupAuthentication } from '.';
@ -132,21 +131,23 @@ describe('setupAuthentication()', () => {
it('replies with no credentials when security is disabled in elasticsearch', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
const mockResponse = httpServerMock.createLifecycleResponseFactory();
mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false }));
await authHandler(mockRequest, mockAuthToolkit);
await authHandler(mockRequest, mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
expect(mockResponse.internalError).not.toHaveBeenCalled();
expect(authenticate).not.toHaveBeenCalled();
});
it('continues request with credentials on success', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
const mockResponse = httpServerMock.createLifecycleResponseFactory();
const mockUser = mockAuthenticatedUser();
const mockAuthHeaders = { authorization: 'Basic xxx' };
@ -154,15 +155,15 @@ describe('setupAuthentication()', () => {
AuthenticationResult.succeeded(mockUser, { authHeaders: mockAuthHeaders })
);
await authHandler(mockRequest, mockAuthToolkit);
await authHandler(mockRequest, mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({
state: mockUser,
requestHeaders: mockAuthHeaders,
});
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
expect(mockResponse.internalError).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledTimes(1);
expect(authenticate).toHaveBeenCalledWith(mockRequest);
@ -170,6 +171,7 @@ describe('setupAuthentication()', () => {
it('returns authentication response headers on success if any', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
const mockResponse = httpServerMock.createLifecycleResponseFactory();
const mockUser = mockAuthenticatedUser();
const mockAuthHeaders = { authorization: 'Basic xxx' };
const mockAuthResponseHeaders = { 'WWW-Authenticate': 'Negotiate' };
@ -181,7 +183,7 @@ describe('setupAuthentication()', () => {
})
);
await authHandler(mockRequest, mockAuthToolkit);
await authHandler(mockRequest, mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({
@ -189,53 +191,66 @@ describe('setupAuthentication()', () => {
requestHeaders: mockAuthHeaders,
responseHeaders: mockAuthResponseHeaders,
});
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
expect(mockResponse.internalError).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledTimes(1);
expect(authenticate).toHaveBeenCalledWith(mockRequest);
});
it('redirects user if redirection is requested by the authenticator', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url'));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.redirected).toHaveBeenCalledWith('/some/url');
expect(mockResponse.redirected).toHaveBeenCalledTimes(1);
expect(mockResponse.redirected).toHaveBeenCalledWith(undefined, {
headers: { location: '/some/url' },
});
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(mockResponse.internalError).not.toHaveBeenCalled();
});
it('rejects with `Internal Server Error` when `authenticate` throws unhandled exception', async () => {
it('rejects with `Internal Server Error` and log error when `authenticate` throws unhandled exception', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
authenticate.mockRejectedValue(new Error('something went wrong'));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe('something went wrong');
expect(getErrorStatusCode(error)).toBe(500);
expect(mockResponse.internalError).toHaveBeenCalledTimes(1);
const [[error]] = mockResponse.internalError.mock.calls;
expect(error).toBeUndefined();
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
expect(loggingServiceMock.collect(mockSetupAuthenticationParams.loggers).error)
.toMatchInlineSnapshot(`
Array [
Array [
[Error: something went wrong],
],
]
`);
});
it('rejects with wrapped original error when `authenticate` fails to authenticate user', async () => {
it('rejects with original `badRequest` error when `authenticate` fails to authenticate user', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
const esError = Boom.badRequest('some message');
authenticate.mockResolvedValue(AuthenticationResult.failed(esError));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(mockResponse.customError).toHaveBeenCalledTimes(1);
const [[error]] = mockResponse.customError.mock.calls;
expect(error).toBe(esError);
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
});
it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
const originalError = Boom.unauthorized('some message');
originalError.output.headers['WWW-Authenticate'] = [
'Basic realm="Access to prod", charset="UTF-8"',
@ -248,29 +263,30 @@ describe('setupAuthentication()', () => {
})
);
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe(originalError.message);
expect((error as Boom).output.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' });
expect(mockResponse.customError).toHaveBeenCalledTimes(1);
const [[error, options]] = mockResponse.customError.mock.calls;
expect(error).toBe(originalError);
expect(options!.headers).toEqual({ 'WWW-Authenticate': 'Negotiate' });
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
});
it('returns `unauthorized` when authentication can not be handled', async () => {
const mockResponse = httpServerMock.createLifecycleResponseFactory();
authenticate.mockResolvedValue(AuthenticationResult.notHandled());
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe('Unauthorized');
expect(getErrorStatusCode(error)).toBe(401);
expect(mockResponse.unauthorized).toHaveBeenCalledTimes(1);
const [[error]] = mockResponse.unauthorized.mock.calls;
expect(error).toBeUndefined();
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockResponse.redirected).not.toHaveBeenCalled();
});
});

View file

@ -3,8 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
ClusterClient,
CoreSetup,
@ -13,7 +11,7 @@ import {
} from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../common/model';
import { ConfigType } from '../config';
import { getErrorStatusCode, wrapError } from '../errors';
import { getErrorStatusCode } from '../errors';
import { Authenticator, ProviderSession } from './authenticator';
import { LegacyAPI } from '../plugin';
import { createAPIKey, CreateAPIKeyOptions } from './api_keys';
@ -77,7 +75,7 @@ export async function setupAuthentication({
authLogger.debug('Successfully initialized authenticator.');
core.http.registerAuth(async (request, t) => {
core.http.registerAuth(async (request, response, t) => {
// If security is disabled continue with no user credentials and delete the client cookie as well.
if (isSecurityFeatureDisabled()) {
return t.authenticated();
@ -88,7 +86,7 @@ export async function setupAuthentication({
authenticationResult = await authenticator.authenticate(request);
} catch (err) {
authLogger.error(err);
return t.rejected(wrapError(err));
return response.internalError();
}
if (authenticationResult.succeeded()) {
@ -105,28 +103,34 @@ export async function setupAuthentication({
// authentication (username and password) or arbitrary external page managed by 3rd party
// Identity Provider for SSO authentication mechanisms. Authentication provider is the one who
// decides what location user should be redirected to.
return t.redirected(authenticationResult.redirectURL!);
return response.redirected(undefined, {
headers: {
location: authenticationResult.redirectURL!,
},
});
}
let error;
if (authenticationResult.failed()) {
authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`);
error = wrapError(authenticationResult.error);
const authResponseHeaders = authenticationResult.authResponseHeaders;
for (const [headerName, headerValue] of Object.entries(authResponseHeaders || {})) {
if (error.output.headers[headerName] !== undefined) {
authLogger.warn(`Server rewrites a error response header [${headerName}].`);
}
// Hapi typings don't support headers that are `string[]`.
error.output.headers[headerName] = headerValue as any;
const error = authenticationResult.error!;
// proxy Elasticsearch "native" errors
const statusCode = getErrorStatusCode(error);
if (typeof statusCode === 'number') {
return response.customError(error, {
statusCode,
headers: authenticationResult.authResponseHeaders,
});
}
} else {
authLogger.info('Could not handle authentication attempt');
error = Boom.unauthorized();
return response.unauthorized(undefined, {
headers: authenticationResult.authResponseHeaders,
});
}
return t.rejected(error);
authLogger.info('Could not handle authentication attempt');
return response.unauthorized(undefined, {
headers: authenticationResult.authResponseHeaders,
});
});
authLogger.debug('Successfully registered core authentication handler.');