diff --git a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md index e41c355e97239..88d199fc1b536 100644 --- a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md +++ b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type AuthenticationHandler = (request: Readonly, sessionStorage: SessionStorage, t: AuthToolkit) => AuthResult | Promise; +export declare type AuthenticationHandler = (request: Readonly, t: AuthToolkit) => AuthResult | Promise; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md index adcbe30d24bf5..e28950653b60d 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md @@ -9,5 +9,5 @@ Authentication is successful with given credentials, allow request to pass throu Signature: ```typescript -authenticated: (state: object) => AuthResult; +authenticated: (state?: object) => AuthResult; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md index 519de0e7ff276..f32f7076f0119 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -16,7 +16,7 @@ export interface AuthToolkit | Property | Type | Description | | --- | --- | --- | -| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (state: object) => AuthResult | Authentication is successful with given credentials, allow request to pass through | +| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (state?: object) => AuthResult | Authentication is successful with given credentials, allow request to pass through | | [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | (url: string) => AuthResult | Authentication requires to interrupt request handling and redirect to a configured url | | [rejected](./kibana-plugin-server.authtoolkit.rejected.md) | (error: Error, options?: {
statusCode?: number;
}) => AuthResult | Authentication is unsuccessful, fail the request with specified error. | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md deleted file mode 100644 index 28f7c8eaa308e..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.from.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [from](./kibana-plugin-server.kibanarequest.from.md) - -## KibanaRequest.from() method - -Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. - -Signature: - -```typescript -static from

(req: Request, routeSchemas?: RouteSchemas): KibanaRequest; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| req | Request | | -| routeSchemas | RouteSchemas<P, Q, B> | | - -Returns: - -`KibanaRequest` - diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.md index b14be4864ffe4..a09632febe531 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -33,7 +33,5 @@ export declare class KibanaRequeststatic | 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) | | | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md deleted file mode 100644 index 96474eb669335..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaRequest](./kibana-plugin-server.kibanarequest.md) > [unstable\_getIncomingMessage](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md) - -## KibanaRequest.unstable\_getIncomingMessage() method - -Signature: - -```typescript -unstable_getIncomingMessage(): import("http").IncomingMessage; -``` -Returns: - -`import("http").IncomingMessage` - diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index e2168e6471cc9..7b7d3a9f0662e 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -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 diff --git a/docs/development/core/server/kibana-plugin-server.sessionstorage.clear.md b/docs/development/core/server/kibana-plugin-server.sessionstorage.clear.md new file mode 100644 index 0000000000000..1f5813e181548 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstorage.clear.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorage](./kibana-plugin-server.sessionstorage.md) > [clear](./kibana-plugin-server.sessionstorage.clear.md) + +## SessionStorage.clear() method + +Clears current session. + +Signature: + +```typescript +clear(): void; +``` +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.sessionstorage.get.md b/docs/development/core/server/kibana-plugin-server.sessionstorage.get.md new file mode 100644 index 0000000000000..26c63884ee71a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstorage.get.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorage](./kibana-plugin-server.sessionstorage.md) > [get](./kibana-plugin-server.sessionstorage.get.md) + +## SessionStorage.get() method + +Retrieves session value from the session storage. + +Signature: + +```typescript +get(): Promise; +``` +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-server.sessionstorage.md b/docs/development/core/server/kibana-plugin-server.sessionstorage.md new file mode 100644 index 0000000000000..02e48c1dd3dc4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstorage.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorage](./kibana-plugin-server.sessionstorage.md) + +## SessionStorage interface + +Provides an interface to store and retrieve data across requests. + +Signature: + +```typescript +export interface SessionStorage +``` + +## 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. | + diff --git a/docs/development/core/server/kibana-plugin-server.sessionstorage.set.md b/docs/development/core/server/kibana-plugin-server.sessionstorage.set.md new file mode 100644 index 0000000000000..7e3a2a4361244 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstorage.set.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorage](./kibana-plugin-server.sessionstorage.md) > [set](./kibana-plugin-server.sessionstorage.set.md) + +## SessionStorage.set() method + +Puts current session value into the session storage. + +Signature: + +```typescript +set(sessionValue: T): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| sessionValue | T | value to put | + +Returns: + +`void` + diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md new file mode 100644 index 0000000000000..ed107ae50899b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.asscoped.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) > [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) + +## SessionStorageFactory.asScoped property + +Signature: + +```typescript +asScoped: (request: Readonly | KibanaRequest) => SessionStorage; +``` diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md new file mode 100644 index 0000000000000..8f6f58902fde4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragefactory.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) + +## SessionStorageFactory interface + +SessionStorage factory to bind one to an incoming request + +Signature: + +```typescript +export interface SessionStorageFactory +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [asScoped](./kibana-plugin-server.sessionstoragefactory.asscoped.md) | (request: Readonly<Request> | KibanaRequest) => SessionStorage<T> | | + diff --git a/src/core/server/http/auth_state_storage.ts b/src/core/server/http/auth_state_storage.ts index eafe755b79eea..bd7bf1e62968c 100644 --- a/src/core/server/http/auth_state_storage.ts +++ b/src/core/server/http/auth_state_storage.ts @@ -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, unknown>(); + private readonly storage = new WeakMap, 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 diff --git a/src/core/server/http/cookie_session_storage.ts b/src/core/server/http/cookie_session_storage.ts index 7301de6315606..f0cd50053cf14 100644 --- a/src/core/server/http/cookie_session_storage.ts +++ b/src/core/server/http/cookie_session_storage.ts @@ -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 { @@ -29,10 +31,10 @@ export interface SessionStorageCookieOptions { } class ScopedCookieSessionStorage> implements SessionStorage { - constructor(private readonly server: Server, private readonly request: Request) {} + constructor(private readonly server: Server, private readonly request: Readonly) {} public async get(): Promise { 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( }); return { - asScoped(request: Request) { - return new ScopedCookieSessionStorage(server, request); + asScoped(request: Readonly | KibanaRequest) { + const req = request instanceof KibanaRequest ? toRawRequest(request) : request; + return new ScopedCookieSessionStorage(server, req); }, }; } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 1e1f452556f67..9eb786ebb586a 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -18,6 +18,8 @@ */ import { Server } from 'http'; +import request from 'request'; +import Boom from 'boom'; jest.mock('fs', () => ({ readFileSync: jest.fn(), @@ -587,17 +589,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); @@ -698,7 +689,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((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((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(''); @@ -707,9 +841,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(); @@ -720,7 +852,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(''); @@ -739,7 +871,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(''); @@ -747,9 +879,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(); @@ -760,7 +890,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); @@ -783,7 +913,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(''); @@ -813,7 +943,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) @@ -830,7 +960,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) @@ -855,11 +985,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((req, t) => { + sessionStorageFactory.asScoped(req).set({ value: user, expires: Date.now() + 1000 }); return t.authenticated(user); }, cookieOptions); @@ -873,7 +1003,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))); @@ -885,7 +1015,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); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 83eecaa2f3700..ab2d227193dce 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -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: ( - handler: AuthenticationHandler, + handler: AuthenticationHandler, cookieOptions: SessionStorageCookieOptions - ) => Promise; + ) => Promise<{ sessionStorageFactory: SessionStorageFactory }>; /** * 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(); private authRegistered = false; - private basePathCache = new WeakMap< - ReturnType, - string - >(); + private basePathCache = new WeakMap, 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: ( - fn: AuthenticationHandler, - cookieOptions: SessionStorageCookieOptions - ) => this.registerAuth(fn, cookieOptions, config.basePath), + registerAuth: (fn: AuthenticationHandler, cookieOptions: SessionStorageCookieOptions) => + this.registerAuth(fn, cookieOptions, config.basePath), getBasePathFor: this.getBasePathFor.bind(this, config), setBasePathFor: this.setBasePathFor.bind(this), auth: { @@ -234,7 +232,7 @@ export class HttpServer { } private async registerAuth( - fn: AuthenticationHandler, + fn: AuthenticationHandler, cookieOptions: SessionStorageCookieOptions, basePath?: string ) { @@ -246,14 +244,14 @@ export class HttpServer { } this.authRegistered = true; - const sessionStorage = await createCookieSessionStorageFactory( + const sessionStorageFactory = await createCookieSessionStorageFactory( 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'); @@ -262,5 +260,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 }; } } diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index f1ab7c629689b..056ee53cee89b 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -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'; diff --git a/src/core/server/http/integration_tests/http_service.test.ts b/src/core/server/http/integration_tests/http_service.test.ts index 93fe20a80e120..100efc4e2a607 100644 --- a/src/core/server/http/integration_tests/http_service.test.ts +++ b/src/core/server/http/integration_tests/http_service.test.ts @@ -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 = 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 = 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 = 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 = async (req, sessionStorage, t) => { + const { http } = await root.setup(); + const { sessionStorageFactory } = await 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(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 = async (req, sessionStorage, t) => { + + const { http } = await root.setup(); + const { sessionStorageFactory } = await http.registerAuth((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 = 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()', () => { diff --git a/src/core/server/http/lifecycle/auth.test.ts b/src/core/server/http/lifecycle/auth.test.ts index f67650d490b03..031556c70483c 100644 --- a/src/core/server/http/lifecycle/auth.test.ts +++ b/src/core/server/http/lifecycle/auth.test.ts @@ -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() diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts index ffe77e0120fe4..bcb7e454b4119 100644 --- a/src/core/server/http/lifecycle/auth.ts +++ b/src/core/server/http/lifecycle/auth.ts @@ -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 = ( +export type AuthenticationHandler = ( request: Readonly, - sessionStorage: SessionStorage, t: AuthToolkit ) => AuthResult | Promise; /** @public */ -export function adoptToHapiAuthFormat( - fn: AuthenticationHandler, - sessionStorage: SessionStorageFactory, +export function adoptToHapiAuthFormat( + fn: AuthenticationHandler, onSuccess: (req: Request, state: unknown) => void = noop ) { return async function interceptAuth( @@ -111,7 +108,7 @@ export function adoptToHapiAuthFormat( h: ResponseToolkit ): Promise { 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}.` diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index 9fc9d530ccaa2..cb941326e23f1 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -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'; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 82db991f8e392..3c235ffbf8bd9 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -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 { /** * Factory for creating requests. Validates the request before creating an * instance of a KibanaRequest. + * @internal */ public static from

( req: Request, @@ -87,14 +90,19 @@ export class KibanaRequest { public readonly url: Url; public readonly route: RecursiveReadonly; + /** @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 { 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]; diff --git a/src/core/server/http/session_storage.ts b/src/core/server/http/session_storage.ts index 4f9d28991fe78..2c726ce34a3cb 100644 --- a/src/core/server/http/session_storage.ts +++ b/src/core/server/http/session_storage.ts @@ -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 { /** @@ -37,6 +39,9 @@ export interface SessionStorage { clear(): void; } +/** + * SessionStorage factory to bind one to an incoming request + * @public */ export interface SessionStorageFactory { - asScoped: (request: Request) => SessionStorage; + asScoped: (request: Readonly | KibanaRequest) => SessionStorage; } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a6cb044ca2c54..4cebe24f39f15 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -62,6 +62,8 @@ export { Router, RouteMethod, RouteConfigOptions, + SessionStorageFactory, + SessionStorage, } from './http'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 442e7b79440e4..88ebe1e54bdce 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -22,15 +22,14 @@ import { Url } from 'url'; // @public (undocumented) export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; -// 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 = (request: Readonly, sessionStorage: SessionStorage, t: AuthToolkit) => AuthResult | Promise; +export type AuthenticationHandler = (request: Readonly, t: AuthToolkit) => AuthResult | Promise; // @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 { + // @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

(req: Request, routeSchemas?: RouteSchemas): KibanaRequest; // (undocumented) getFilteredHeaders(headersToKeep: string[]): Pick, string>; @@ -183,8 +186,6 @@ export class KibanaRequest { // (undocumented) readonly route: RecursiveReadonly; // (undocumented) - unstable_getIncomingMessage(): import("http").IncomingMessage; - // (undocumented) readonly url: Url; } @@ -386,6 +387,19 @@ export class ScopedClusterClient { callAsInternalUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; } +// @public +export interface SessionStorage { + clear(): void; + get(): Promise; + set(sessionValue: T): void; +} + +// @public +export interface SessionStorageFactory { + // (undocumented) + asScoped: (request: Readonly | KibanaRequest) => SessionStorage; +} + // Warnings were encountered during analysis: //