Session storage refactoring (#37992) (#38385)

* Kibana request keep a reference to raw request. used to bind hapi-cookie

* CookieSessionStorage should work with KibanaRequest

as soon as registerAuth refactored to restrict access to hapi Request, CookieSessionStorage won't work with hapi request directly

* change registerAuth public api

* adopt auth lifecycle tests

* move lifecycle auth tests from integration to unit and adopt to new api.

* mark toRawRequest as internal to prevent exposure

* generate docs

* reword test cases

* mark Request internals in tsdoc
This commit is contained in:
Mikhail Shustov 2019-06-07 11:28:41 +02:00 committed by GitHub
parent bc70842706
commit e84bb5cf35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 379 additions and 251 deletions

View file

@ -8,5 +8,5 @@
<b>Signature:</b>
```typescript
export declare type AuthenticationHandler<T> = (request: Readonly<Request>, sessionStorage: SessionStorage<T>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export declare type AuthenticationHandler = (request: Readonly<Request>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
```

View file

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

View file

@ -16,7 +16,7 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(state: object) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | <code>(state?: object) =&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,25 +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; [KibanaRequest](./kibana-plugin-server.kibanarequest.md) &gt; [from](./kibana-plugin-server.kibanarequest.from.md)
## KibanaRequest.from() method
Factory for creating requests. Validates the request before creating an instance of a KibanaRequest.
<b>Signature:</b>
```typescript
static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(req: Request, routeSchemas?: RouteSchemas<P, Q, B>): KibanaRequest<P["type"], Q["type"], B["type"]>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| req | <code>Request</code> | |
| routeSchemas | <code>RouteSchemas&lt;P, Q, B&gt;</code> | |
<b>Returns:</b>
`KibanaRequest<P["type"], Q["type"], B["type"]>`

View file

@ -33,7 +33,5 @@ export declare class KibanaRequest<Params = unknown, Query = unknown, Body = unk
| Method | Modifiers | Description |
| --- | --- | --- |
| [from(req, routeSchemas)](./kibana-plugin-server.kibanarequest.from.md) | <code>static</code> | Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. |
| [getFilteredHeaders(headersToKeep)](./kibana-plugin-server.kibanarequest.getfilteredheaders.md) | | |
| [unstable\_getIncomingMessage()](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md) | | |

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; [KibanaRequest](./kibana-plugin-server.kibanarequest.md) &gt; [unstable\_getIncomingMessage](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md)
## KibanaRequest.unstable\_getIncomingMessage() method
<b>Signature:</b>
```typescript
unstable_getIncomingMessage(): import("http").IncomingMessage;
```
<b>Returns:</b>
`import("http").IncomingMessage`

View file

@ -43,6 +43,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | |
| [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | |
| [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Route specific configuration. |
| [SessionStorage](./kibana-plugin-server.sessionstorage.md) | Provides an interface to store and retrieve data across requests. |
| [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) | SessionStorage factory to bind one to an incoming request |
## Type Aliases

View file

@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorage](./kibana-plugin-server.sessionstorage.md) &gt; [clear](./kibana-plugin-server.sessionstorage.clear.md)
## SessionStorage.clear() method
Clears current session.
<b>Signature:</b>
```typescript
clear(): void;
```
<b>Returns:</b>
`void`

View file

@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorage](./kibana-plugin-server.sessionstorage.md) &gt; [get](./kibana-plugin-server.sessionstorage.get.md)
## SessionStorage.get() method
Retrieves session value from the session storage.
<b>Signature:</b>
```typescript
get(): Promise<T | null>;
```
<b>Returns:</b>
`Promise<T | null>`

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorage](./kibana-plugin-server.sessionstorage.md)
## SessionStorage interface
Provides an interface to store and retrieve data across requests.
<b>Signature:</b>
```typescript
export interface SessionStorage<T>
```
## Methods
| Method | Description |
| --- | --- |
| [clear()](./kibana-plugin-server.sessionstorage.clear.md) | Clears current session. |
| [get()](./kibana-plugin-server.sessionstorage.get.md) | Retrieves session value from the session storage. |
| [set(sessionValue)](./kibana-plugin-server.sessionstorage.set.md) | Puts current session value into the session storage. |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorage](./kibana-plugin-server.sessionstorage.md) &gt; [set](./kibana-plugin-server.sessionstorage.set.md)
## SessionStorage.set() method
Puts current session value into the session storage.
<b>Signature:</b>
```typescript
set(sessionValue: T): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| sessionValue | <code>T</code> | value to put |
<b>Returns:</b>
`void`

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) &gt; [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md)
## SessionStorageFactory.asScoped property
<b>Signature:</b>
```typescript
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md)
## SessionStorageFactory interface
SessionStorage factory to bind one to an incoming request
<b>Signature:</b>
```typescript
export interface SessionStorageFactory<T>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | <code>(request: Readonly&lt;Request&gt; &#124; KibanaRequest) =&gt; SessionStorage&lt;T&gt;</code> | |

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Request } from 'hapi';
import { KibanaRequest } from './router';
import { KibanaRequest, toRawRequest } from './router';
export enum AuthStatus {
authenticated = 'authenticated',
@ -25,17 +25,17 @@ export enum AuthStatus {
unknown = 'unknown',
}
const toKey = (request: KibanaRequest | Request) =>
request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req;
const getIncomingMessage = (request: KibanaRequest | Request) =>
request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req;
export class AuthStateStorage {
private readonly storage = new WeakMap<ReturnType<typeof toKey>, unknown>();
private readonly storage = new WeakMap<ReturnType<typeof getIncomingMessage>, unknown>();
constructor(private readonly canBeAuthenticated: () => boolean) {}
public set = (request: KibanaRequest | Request, state: unknown) => {
this.storage.set(toKey(request), state);
this.storage.set(getIncomingMessage(request), state);
};
public get = (request: KibanaRequest | Request) => {
const key = toKey(request);
const key = getIncomingMessage(request);
const state = this.storage.get(key);
const status: AuthStatus = this.storage.has(key)
? AuthStatus.authenticated

View file

@ -19,6 +19,8 @@
import { Request, Server } from 'hapi';
import hapiAuthCookie from 'hapi-auth-cookie';
import { KibanaRequest, toRawRequest } from './router';
import { SessionStorageFactory, SessionStorage } from './session_storage';
export interface SessionStorageCookieOptions<T> {
@ -29,10 +31,10 @@ export interface SessionStorageCookieOptions<T> {
}
class ScopedCookieSessionStorage<T extends Record<string, any>> implements SessionStorage<T> {
constructor(private readonly server: Server, private readonly request: Request) {}
constructor(private readonly server: Server, private readonly request: Readonly<Request>) {}
public async get(): Promise<T | null> {
try {
return await this.server.auth.test('security-cookie', this.request);
return await this.server.auth.test('security-cookie', this.request as Request);
} catch (error) {
return null;
}
@ -71,8 +73,9 @@ export async function createCookieSessionStorageFactory<T>(
});
return {
asScoped(request: Request) {
return new ScopedCookieSessionStorage<T>(server, request);
asScoped(request: Readonly<Request> | KibanaRequest) {
const req = request instanceof KibanaRequest ? toRawRequest(request) : request;
return new ScopedCookieSessionStorage<T>(server, req);
},
};
}

View file

@ -18,6 +18,8 @@
*/
import { Server } from 'http';
import request from 'request';
import Boom from 'boom';
jest.mock('fs', () => ({
readFileSync: jest.fn(),
@ -586,17 +588,6 @@ test('returns server and connection options on start', async () => {
expect(options).toMatchSnapshot();
});
test('registers auth request interceptor only once', async () => {
const { registerAuth } = await server.setup(config);
const doRegister = () =>
registerAuth(() => null as any, {
encryptionKey: 'any_password',
} as any);
await doRegister();
expect(doRegister()).rejects.toThrowError('Auth interceptor was already registered');
});
test('registers registerOnPostAuth interceptor several times', async () => {
const { registerOnPostAuth } = await server.setup(config);
const doRegister = () => registerOnPostAuth(() => null as any);
@ -697,7 +688,150 @@ const cookieOptions = {
isSecure: false,
};
test('Should enable auth for a route by default if registerAuth has been called', async () => {
interface User {
id: string;
roles?: string[];
}
interface StorageData {
value: User;
expires: number;
}
describe('#registerAuth', () => {
it('registers auth request interceptor only once', async () => {
const { registerAuth } = await server.setup(config);
const doRegister = () =>
registerAuth(() => null as any, {
encryptionKey: 'any_password',
} as any);
await doRegister();
expect(doRegister()).rejects.toThrowError('Auth interceptor was already registered');
});
it('supports implementing custom authentication logic', async () => {
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const { sessionStorageFactory } = await registerAuth<StorageData>((req, t) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return t.authenticated(user);
}, cookieOptions);
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('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()), cookieOptions);
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);
}, cookieOptions);
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');
}, cookieOptions);
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 from route handler`, async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const { sessionStorageFactory } = await registerAuth<StorageData>((req, t) => {
const user = { id: '42' };
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + 1000 });
return t.authenticated();
}, cookieOptions);
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=/',
]);
});
});
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('');
@ -706,9 +840,7 @@ test('Should enable auth for a route by default if registerAuth has been called'
);
registerRouter(router);
const authenticate = jest
.fn()
.mockImplementation((req, sessionStorage, t) => t.authenticated({}));
const authenticate = jest.fn().mockImplementation((req, t) => t.authenticated());
await registerAuth(authenticate, cookieOptions);
await server.start();
@ -719,7 +851,7 @@ test('Should enable auth for a route by default if registerAuth has been called'
expect(authenticate).toHaveBeenCalledTimes(1);
});
test('Should support disabling auth for a route explicitly', async () => {
test('supports disabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
@ -738,7 +870,7 @@ test('Should support disabling auth for a route explicitly', async () => {
expect(authenticate).toHaveBeenCalledTimes(0);
});
test('Should support enabling auth for a route explicitly', async () => {
test('supports enabling auth for a route explicitly', async () => {
const { registerAuth, registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
@ -746,9 +878,7 @@ test('Should support enabling auth for a route explicitly', async () => {
res.ok({ authRequired: req.route.options.authRequired })
);
registerRouter(router);
const authenticate = jest
.fn()
.mockImplementation((req, sessionStorage, t) => t.authenticated({}));
const authenticate = jest.fn().mockImplementation((req, t) => t.authenticated({}));
await registerAuth(authenticate, cookieOptions);
await server.start();
@ -759,7 +889,7 @@ test('Should support enabling auth for a route explicitly', async () => {
expect(authenticate).toHaveBeenCalledTimes(1);
});
test('Should allow attaching metadata to attach meta-data tag strings to a route', async () => {
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);
@ -782,7 +912,7 @@ test('Should allow attaching metadata to attach meta-data tag strings to a route
.expect(200, { tags: [] });
});
test('Should expose route details of incoming request to a route handler', async () => {
test('exposes route details of incoming request to a route handler', async () => {
const { registerRouter, server: innerServer } = await server.setup(config);
const router = new Router('');
@ -812,7 +942,7 @@ describe('#auth.isAuthenticated()', () => {
);
registerRouter(router);
await registerAuth((req, sessionStorage, t) => t.authenticated({}), cookieOptions);
await registerAuth((req, t) => t.authenticated(), cookieOptions);
await server.start();
await supertest(innerServer.listener)
@ -829,7 +959,7 @@ describe('#auth.isAuthenticated()', () => {
);
registerRouter(router);
await registerAuth((req, sessionStorage, t) => t.authenticated({}), cookieOptions);
await registerAuth((req, t) => t.authenticated(), cookieOptions);
await server.start();
await supertest(innerServer.listener)
@ -854,11 +984,11 @@ describe('#auth.isAuthenticated()', () => {
});
describe('#auth.get()', () => {
it('Should return authenticated status and allow associate auth state with request', async () => {
it('returns authenticated status and allow associate auth state with request', async () => {
const user = { id: '42' };
const { registerRouter, registerAuth, server: innerServer, auth } = await server.setup(config);
await registerAuth((req, sessionStorage, t) => {
sessionStorage.set({ value: user });
const { sessionStorageFactory } = await registerAuth<StorageData>((req, t) => {
sessionStorageFactory.asScoped(req).set({ value: user, expires: Date.now() + 1000 });
return t.authenticated(user);
}, cookieOptions);
@ -872,7 +1002,7 @@ describe('#auth.get()', () => {
.expect(200, { state: user, status: 'authenticated' });
});
it('Should return correct authentication unknown status', async () => {
it('returns correct authentication unknown status', async () => {
const { registerRouter, server: innerServer, auth } = await server.setup(config);
const router = new Router('');
router.get({ path: '/', validate: false }, (req, res) => res.ok(auth.get(req)));
@ -884,7 +1014,7 @@ describe('#auth.get()', () => {
.expect(200, { status: 'unknown' });
});
it('Should return correct unauthenticated status', async () => {
it('returns correct unauthenticated status', async () => {
const authenticate = jest.fn();
const { registerRouter, registerAuth, server: innerServer, auth } = await server.setup(config);

View file

@ -26,13 +26,17 @@ import { createServer, getServerOptions } from './http_tools';
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth';
import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth';
import { Router, KibanaRequest } from './router';
import { Router, KibanaRequest, toRawRequest } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
} from './cookie_session_storage';
import { SessionStorageFactory } from './session_storage';
import { AuthStateStorage } from './auth_state_storage';
const getIncomingMessage = (request: KibanaRequest | Request) =>
request instanceof KibanaRequest ? toRawRequest(request).raw.req : request.raw.req;
export interface HttpServerSetup {
server: Server;
options: ServerOptions;
@ -44,9 +48,9 @@ export interface HttpServerSetup {
* Only one AuthenticationHandler can be registered.
*/
registerAuth: <T>(
handler: AuthenticationHandler<T>,
handler: AuthenticationHandler,
cookieOptions: SessionStorageCookieOptions<T>
) => Promise<void>;
) => Promise<{ sessionStorageFactory: SessionStorageFactory<T> }>;
/**
* To define custom logic to perform for incoming requests. Runs the handler before Auth
* hook performs a check that user has access to requested resources, so it's the only
@ -76,10 +80,7 @@ export class HttpServer {
private config?: HttpConfig;
private registeredRouters = new Set<Router>();
private authRegistered = false;
private basePathCache = new WeakMap<
ReturnType<KibanaRequest['unstable_getIncomingMessage']>,
string
>();
private basePathCache = new WeakMap<ReturnType<typeof getIncomingMessage>, string>();
private readonly authState: AuthStateStorage;
@ -102,8 +103,7 @@ export class HttpServer {
// passing hapi Request works for BWC. can be deleted once we remove legacy server.
private getBasePathFor(config: HttpConfig, request: KibanaRequest | Request) {
const incomingMessage =
request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req;
const incomingMessage = getIncomingMessage(request);
const requestScopePath = this.basePathCache.get(incomingMessage) || '';
const serverBasePath = config.basePath || '';
@ -112,8 +112,8 @@ export class HttpServer {
// should work only for KibanaRequest as soon as spaces migrate to NP
private setBasePathFor(request: KibanaRequest | Request, basePath: string) {
const incomingMessage =
request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req;
const incomingMessage = getIncomingMessage(request);
if (this.basePathCache.has(incomingMessage)) {
throw new Error(
'Request basePath was previously set. Setting multiple times is not supported.'
@ -134,10 +134,8 @@ export class HttpServer {
registerRouter: this.registerRouter.bind(this),
registerOnPreAuth: this.registerOnPreAuth.bind(this),
registerOnPostAuth: this.registerOnPostAuth.bind(this),
registerAuth: <T>(
fn: AuthenticationHandler<T>,
cookieOptions: SessionStorageCookieOptions<T>
) => this.registerAuth(fn, cookieOptions, config.basePath),
registerAuth: <T>(fn: AuthenticationHandler, cookieOptions: SessionStorageCookieOptions<T>) =>
this.registerAuth(fn, cookieOptions, config.basePath),
getBasePathFor: this.getBasePathFor.bind(this, config),
setBasePathFor: this.setBasePathFor.bind(this),
auth: {
@ -233,7 +231,7 @@ export class HttpServer {
}
private async registerAuth<T>(
fn: AuthenticationHandler<T>,
fn: AuthenticationHandler,
cookieOptions: SessionStorageCookieOptions<T>,
basePath?: string
) {
@ -245,14 +243,14 @@ export class HttpServer {
}
this.authRegistered = true;
const sessionStorage = await createCookieSessionStorageFactory<T>(
const sessionStorageFactory = await createCookieSessionStorageFactory<T>(
this.server,
cookieOptions,
basePath
);
this.server.auth.scheme('login', () => ({
authenticate: adoptToHapiAuthFormat(fn, sessionStorage, this.authState.set),
authenticate: adoptToHapiAuthFormat(fn, this.authState.set),
}));
this.server.auth.strategy('session', 'login');
@ -261,5 +259,7 @@ export class HttpServer {
// should be applied for all routes if they don't specify auth strategy in route declaration
// https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions
this.server.auth.default('session');
return { sessionStorageFactory };
}
}

View file

@ -30,3 +30,4 @@ export { BasePathProxyServer } from './base_path_proxy_server';
export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth';
export { AuthenticationHandler, AuthToolkit } from './lifecycle/auth';
export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth';
export { SessionStorageFactory, SessionStorage } from './session_storage';

View file

@ -16,12 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import request from 'request';
import Boom from 'boom';
import { AuthenticationHandler } from '../../../../core/server';
import { Router } from '../router';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
interface User {
@ -29,7 +26,7 @@ interface User {
roles?: string[];
}
interface Storage {
interface StorageData {
value: User;
expires: number;
}
@ -41,7 +38,7 @@ describe('http service', () => {
const cookieOptions = {
name: 'sid',
encryptionKey: 'something_at_least_32_characters',
validate: (session: Storage) => true,
validate: (session: StorageData) => true,
isSecure: false,
path: '/',
};
@ -53,90 +50,18 @@ describe('http service', () => {
afterEach(async () => await root.shutdown());
it('Should support implementing custom authentication logic', async () => {
const router = new Router('');
router.get({ path: '/', validate: false }, async (req, res) => res.ok({ content: 'ok' }));
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
if (req.headers.authorization) {
const user = { id: '42' };
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated(user);
} else {
return t.rejected(Boom.unauthorized());
}
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
http.registerRouter(router);
await root.start();
const response = await kbnTestServer.request.get(root, '/').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('Should support rejecting a request from an unauthenticated user', async () => {
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
if (req.headers.authorization) {
const user = { id: '42' };
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated(user);
} else {
return t.rejected(Boom.unauthorized());
}
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
await root.start();
await kbnTestServer.request
.get(root, '/')
.unset('Authorization')
.expect(401);
});
it('Should support redirecting', async () => {
const redirectTo = '/redirect-url';
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
return t.redirected(redirectTo);
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
await root.start();
const response = await kbnTestServer.request.get(root, '/').expect(302);
expect(response.header.location).toBe(redirectTo);
});
it('Should run auth for legacy routes and proxy request to legacy server route handlers', async () => {
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
const { http } = await root.setup();
const { sessionStorageFactory } = await http.registerAuth<StorageData>((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(user);
} else {
return t.rejected(Boom.unauthorized());
}
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
}, cookieOptions);
await root.start();
const legacyUrl = '/legacy';
@ -156,17 +81,17 @@ describe('http service', () => {
it('Should pass associated auth state to Legacy platform', async () => {
const user = { id: '42' };
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
const { http } = await root.setup();
const { sessionStorageFactory } = await http.registerAuth<StorageData>((req, t) => {
if (req.headers.authorization) {
const sessionStorage = sessionStorageFactory.asScoped(req);
sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs });
return t.authenticated(user);
} else {
return t.rejected(Boom.unauthorized());
}
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
}, cookieOptions);
await root.start();
const legacyUrl = '/legacy';
@ -183,22 +108,6 @@ describe('http service', () => {
expect(response.header['set-cookie']).toBe(undefined);
});
it(`Shouldn't expose internal error details`, async () => {
const authenticate: AuthenticationHandler<Storage> = async (req, sessionStorage, t) => {
throw new Error('sensitive info');
};
const { http } = await root.setup();
await http.registerAuth(authenticate, cookieOptions);
await root.start();
await kbnTestServer.request.get(root, '/').expect({
statusCode: 500,
error: 'Internal Server Error',
message: 'An internal server error occurred',
});
});
});
describe('#registerOnPostAuth()', () => {

View file

@ -21,18 +21,11 @@ import Boom from 'boom';
import { adoptToHapiAuthFormat } from './auth';
import { httpServerMock } from '../http_server.mocks';
const SessionStorageMock = {
asScoped: () => null as any,
};
describe('adoptToHapiAuthFormat', () => {
it('Should allow authenticating a user identity with given credentials', async () => {
const credentials = {};
const authenticatedMock = jest.fn();
const onAuth = adoptToHapiAuthFormat(
async (req, sessionStorage, t) => t.authenticated(credentials),
SessionStorageMock
);
const onAuth = adoptToHapiAuthFormat((req, t) => t.authenticated(credentials));
await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit({
@ -46,10 +39,7 @@ describe('adoptToHapiAuthFormat', () => {
it('Should allow redirecting to specified url', async () => {
const redirectUrl = '/docs';
const onAuth = adoptToHapiAuthFormat(
async (req, sessionStorage, t) => t.redirected(redirectUrl),
SessionStorageMock
);
const onAuth = adoptToHapiAuthFormat((req, t) => t.redirected(redirectUrl));
const takeoverSymbol = {};
const redirectMock = jest.fn(() => ({ takeover: () => takeoverSymbol }));
const result = await onAuth(
@ -64,9 +54,8 @@ describe('adoptToHapiAuthFormat', () => {
});
it('Should allow to specify statusCode and message for Boom error', async () => {
const onAuth = adoptToHapiAuthFormat(
async (req, sessionStorage, t) => t.rejected(new Error('not found'), { statusCode: 404 }),
SessionStorageMock
const onAuth = adoptToHapiAuthFormat((req, t) =>
t.rejected(new Error('not found'), { statusCode: 404 })
);
const result = (await onAuth(
httpServerMock.createRawRequest(),
@ -79,9 +68,9 @@ describe('adoptToHapiAuthFormat', () => {
});
it('Should return Boom.internal error error if interceptor throws', async () => {
const onAuth = adoptToHapiAuthFormat(async (req, sessionStorage, t) => {
const onAuth = adoptToHapiAuthFormat((req, t) => {
throw new Error('unknown error');
}, SessionStorageMock);
});
const result = (await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()
@ -93,10 +82,7 @@ describe('adoptToHapiAuthFormat', () => {
});
it('Should return Boom.internal error if interceptor returns unexpected result', async () => {
const onAuth = adoptToHapiAuthFormat(
async (req, sessionStorage, t) => undefined as any,
SessionStorageMock
);
const onAuth = adoptToHapiAuthFormat(async (req, t) => undefined as any);
const result = (await onAuth(
httpServerMock.createRawRequest(),
httpServerMock.createRawResponseToolkit()

View file

@ -19,7 +19,6 @@
import Boom from 'boom';
import { noop } from 'lodash';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { SessionStorage, SessionStorageFactory } from '../session_storage';
enum ResultType {
authenticated = 'authenticated',
@ -46,7 +45,7 @@ interface Rejected {
type AuthResult = Authenticated | Rejected | Redirected;
const authResult = {
authenticated(state: object): AuthResult {
authenticated(state: object = {}): AuthResult {
return { type: ResultType.authenticated, state };
},
redirected(url: string): AuthResult {
@ -80,7 +79,7 @@ const authResult = {
*/
export interface AuthToolkit {
/** Authentication is successful with given credentials, allow request to pass through */
authenticated: (state: object) => AuthResult;
authenticated: (state?: object) => 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. */
@ -94,16 +93,14 @@ const toolkit: AuthToolkit = {
};
/** @public */
export type AuthenticationHandler<T> = (
export type AuthenticationHandler = (
request: Readonly<Request>,
sessionStorage: SessionStorage<T>,
t: AuthToolkit
) => AuthResult | Promise<AuthResult>;
/** @public */
export function adoptToHapiAuthFormat<T = any>(
fn: AuthenticationHandler<T>,
sessionStorage: SessionStorageFactory<T>,
export function adoptToHapiAuthFormat(
fn: AuthenticationHandler,
onSuccess: (req: Request, state: unknown) => void = noop
) {
return async function interceptAuth(
@ -111,7 +108,7 @@ export function adoptToHapiAuthFormat<T = any>(
h: ResponseToolkit
): Promise<Lifecycle.ReturnValue> {
try {
const result = await fn(req, sessionStorage.asScoped(req), toolkit);
const result = await fn(req, toolkit);
if (!authResult.isValid(result)) {
throw new Error(
`Unexpected result from Authenticate. Expected AuthResult, but given: ${result}.`

View file

@ -19,5 +19,5 @@
export { Headers, filterHeaders } from './headers';
export { Router } from './router';
export { KibanaRequest, KibanaRequestRoute } from './request';
export { KibanaRequest, KibanaRequestRoute, toRawRequest } from './request';
export { RouteMethod, RouteConfigOptions } from './route';

View file

@ -25,6 +25,8 @@ import { deepFreeze, RecursiveReadonly } from '../../../utils';
import { filterHeaders, Headers } from './headers';
import { RouteMethod, RouteSchemas, RouteConfigOptions } from './route';
const requestSymbol = Symbol('request');
/**
* Request specific route information exposed to a handler.
* @public
@ -43,6 +45,7 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
/**
* Factory for creating requests. Validates the request before creating an
* instance of a KibanaRequest.
* @internal
*/
public static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(
req: Request,
@ -87,14 +90,19 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
public readonly url: Url;
public readonly route: RecursiveReadonly<KibanaRequestRoute>;
/** @internal */
protected readonly [requestSymbol]: Request;
constructor(
private readonly request: Request,
request: Request,
readonly params: Params,
readonly query: Query,
readonly body: Body
) {
this.headers = request.headers;
this.url = request.url;
this[requestSymbol] = request;
this.route = deepFreeze(this.getRouteInfo());
}
@ -102,19 +110,21 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
return filterHeaders(this.headers, headersToKeep);
}
// eslint-disable-next-line @typescript-eslint/camelcase
public unstable_getIncomingMessage() {
return this.request.raw.req;
}
private getRouteInfo() {
const request = this[requestSymbol];
return {
path: this.request.path,
method: this.request.method,
path: request.path,
method: request.method,
options: {
authRequired: this.request.route.settings.auth !== false,
tags: this.request.route.settings.tags || [],
authRequired: request.route.settings.auth !== false,
tags: request.route.settings.tags || [],
},
};
}
}
/**
* Returns underlying Hapi Request object for KibanaRequest
* @internal
*/
export const toRawRequest = (request: KibanaRequest) => request[requestSymbol];

View file

@ -18,8 +18,10 @@
*/
import { Request } from 'hapi';
import { KibanaRequest } from './router';
/**
* Provides an interface to store and retrieve data across requests.
* @public
*/
export interface SessionStorage<T> {
/**
@ -37,6 +39,9 @@ export interface SessionStorage<T> {
clear(): void;
}
/**
* SessionStorage factory to bind one to an incoming request
* @public */
export interface SessionStorageFactory<T> {
asScoped: (request: Request) => SessionStorage<T>;
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
}

View file

@ -62,6 +62,8 @@ export {
Router,
RouteMethod,
RouteConfigOptions,
SessionStorageFactory,
SessionStorage,
} from './http';
export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging';

View file

@ -22,15 +22,14 @@ import { Url } from 'url';
// @public (undocumented)
export type APICaller = (endpoint: string, clientParams: Record<string, unknown>, options?: CallAPIOptions) => Promise<unknown>;
// Warning: (ae-forgotten-export) The symbol "SessionStorage" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "AuthResult" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export type AuthenticationHandler<T> = (request: Readonly<Request>, sessionStorage: SessionStorage<T>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
export type AuthenticationHandler = (request: Readonly<Request>, t: AuthToolkit) => AuthResult | Promise<AuthResult>;
// @public
export interface AuthToolkit {
authenticated: (state: object) => AuthResult;
authenticated: (state?: object) => AuthResult;
redirected: (url: string) => AuthResult;
rejected: (error: Error, options?: {
statusCode?: number;
@ -167,10 +166,14 @@ export interface InternalCoreStart {
// @public
export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
// @internal (undocumented)
protected readonly [requestSymbol]: Request;
constructor(request: Request, params: Params, query: Query, body: Body);
// (undocumented)
readonly body: Body;
// Warning: (ae-forgotten-export) The symbol "RouteSchemas" needs to be exported by the entry point index.d.ts
//
// @internal
static from<P extends ObjectType, Q extends ObjectType, B extends ObjectType>(req: Request, routeSchemas?: RouteSchemas<P, Q, B>): KibanaRequest<P["type"], Q["type"], B["type"]>;
// (undocumented)
getFilteredHeaders(headersToKeep: string[]): Pick<Record<string, string | string[] | undefined>, string>;
@ -183,8 +186,6 @@ export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
// (undocumented)
readonly route: RecursiveReadonly<KibanaRequestRoute>;
// (undocumented)
unstable_getIncomingMessage(): import("http").IncomingMessage;
// (undocumented)
readonly url: Url;
}
@ -386,6 +387,19 @@ export class ScopedClusterClient {
callAsInternalUser(endpoint: string, clientParams?: Record<string, unknown>, options?: CallAPIOptions): Promise<unknown>;
}
// @public
export interface SessionStorage<T> {
clear(): void;
get(): Promise<T | null>;
set(sessionValue: T): void;
}
// @public
export interface SessionStorageFactory<T> {
// (undocumented)
asScoped: (request: Readonly<Request> | KibanaRequest) => SessionStorage<T>;
}
// Warnings were encountered during analysis:
//