diff --git a/docs/development/core/server/kibana-plugin-server.authenticationhandler.md b/docs/development/core/server/kibana-plugin-server.authenticationhandler.md index 1437d5083df2d2..ced5cfd9413ec5 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: KibanaRequest, t: AuthToolkit) => AuthResult | Promise; +export declare type AuthenticationHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit) => AuthResult | KibanaResponse | Promise; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md index 85bc0b42042412..0c030ddce4ec33 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -17,6 +17,4 @@ export interface AuthToolkit | Property | Type | Description | | --- | --- | --- | | [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (data?: AuthResultParams) => 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.authtoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md deleted file mode 100644 index eb07b1c4b0f647..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.redirected.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [redirected](./kibana-plugin-server.authtoolkit.redirected.md) - -## AuthToolkit.redirected property - -Authentication requires to interrupt request handling and redirect to a configured url - -Signature: - -```typescript -redirected: (url: string) => AuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md deleted file mode 100644 index bc353c7df9fba0..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.rejected.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthToolkit](./kibana-plugin-server.authtoolkit.md) > [rejected](./kibana-plugin-server.authtoolkit.rejected.md) - -## AuthToolkit.rejected property - -Authentication is unsuccessful, fail the request with specified error. - -Signature: - -```typescript -rejected: (error: Error, options?: { - statusCode?: number; - }) => AuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.lifecycleresponsefactory.md b/docs/development/core/server/kibana-plugin-server.lifecycleresponsefactory.md new file mode 100644 index 00000000000000..f1c427203dd24e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.lifecycleresponsefactory.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [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. + +Signature: + +```typescript +export declare type LifecycleResponseFactory = typeof lifecycleResponseFactory; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 9d5432312a645c..d5e8b899d26e85 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -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 plugin export at the root of a plugin's server directory should conform to this interface. | diff --git a/docs/development/core/server/kibana-plugin-server.onpostauthhandler.md b/docs/development/core/server/kibana-plugin-server.onpostauthhandler.md index b28f55233d5482..884eb3e6346bdd 100644 --- a/docs/development/core/server/kibana-plugin-server.onpostauthhandler.md +++ b/docs/development/core/server/kibana-plugin-server.onpostauthhandler.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type OnPostAuthHandler = (request: KibanaRequest, t: OnPostAuthToolkit) => OnPostAuthResult | Promise; +export declare type OnPostAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPostAuthToolkit) => OnPostAuthResult | KibanaResponse | Promise; ``` diff --git a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.md b/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.md index b9d7a1463a2006..001c14c53fecb3 100644 --- a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.md @@ -17,6 +17,4 @@ export interface OnPostAuthToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-server.onpostauthtoolkit.next.md) | () => OnPostAuthResult | To pass request to the next handler | -| [redirected](./kibana-plugin-server.onpostauthtoolkit.redirected.md) | (url: string) => OnPostAuthResult | To interrupt request handling and redirect to a configured url | -| [rejected](./kibana-plugin-server.onpostauthtoolkit.rejected.md) | (error: Error, options?: {
statusCode?: number;
}) => OnPostAuthResult | Fail the request with specified error. | diff --git a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.redirected.md deleted file mode 100644 index 94eab27724c8cd..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.redirected.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) > [redirected](./kibana-plugin-server.onpostauthtoolkit.redirected.md) - -## OnPostAuthToolkit.redirected property - -To interrupt request handling and redirect to a configured url - -Signature: - -```typescript -redirected: (url: string) => OnPostAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.rejected.md b/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.rejected.md deleted file mode 100644 index 00efb4fde305ee..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.onpostauthtoolkit.rejected.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) > [rejected](./kibana-plugin-server.onpostauthtoolkit.rejected.md) - -## OnPostAuthToolkit.rejected property - -Fail the request with specified error. - -Signature: - -```typescript -rejected: (error: Error, options?: { - statusCode?: number; - }) => OnPostAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.onpreauthhandler.md b/docs/development/core/server/kibana-plugin-server.onpreauthhandler.md index 8374f83fc810ce..5eca904227bb9e 100644 --- a/docs/development/core/server/kibana-plugin-server.onpreauthhandler.md +++ b/docs/development/core/server/kibana-plugin-server.onpreauthhandler.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type OnPreAuthHandler = (request: KibanaRequest, t: OnPreAuthToolkit) => OnPreAuthResult | Promise; +export declare type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit) => OnPreAuthResult | KibanaResponse | Promise; ``` diff --git a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.md b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.md index 787c9010372e03..174f377eec292f 100644 --- a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.md @@ -17,6 +17,5 @@ export interface OnPreAuthToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-server.onpreauthtoolkit.next.md) | () => OnPreAuthResult | To pass request to the next handler | -| [redirected](./kibana-plugin-server.onpreauthtoolkit.redirected.md) | (url: string, options?: {
forward: boolean;
}) => 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. | -| [rejected](./kibana-plugin-server.onpreauthtoolkit.rejected.md) | (error: Error, options?: {
statusCode?: number;
}) => OnPreAuthResult | Fail the request with specified error. | +| [rewriteUrl](./kibana-plugin-server.onpreauthtoolkit.rewriteurl.md) | (url: string) => OnPreAuthResult | Rewrite requested resources url before is was authenticated and routed to a handler | diff --git a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.redirected.md b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.redirected.md deleted file mode 100644 index 77deb5b61c4e25..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.redirected.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) > [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. - -Signature: - -```typescript -redirected: (url: string, options?: { - forward: boolean; - }) => OnPreAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rejected.md b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rejected.md deleted file mode 100644 index 1fd79d0b5766ba..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rejected.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) > [rejected](./kibana-plugin-server.onpreauthtoolkit.rejected.md) - -## OnPreAuthToolkit.rejected property - -Fail the request with specified error. - -Signature: - -```typescript -rejected: (error: Error, options?: { - statusCode?: number; - }) => OnPreAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rewriteurl.md new file mode 100644 index 00000000000000..0f401379c20fdf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.onpreauthtoolkit.rewriteurl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) > [rewriteUrl](./kibana-plugin-server.onpreauthtoolkit.rewriteurl.md) + +## OnPreAuthToolkit.rewriteUrl property + +Rewrite requested resources url before is was authenticated and routed to a handler + +Signature: + +```typescript +rewriteUrl: (url: string) => OnPreAuthResult; +``` diff --git a/docs/development/core/server/kibana-plugin-server.router.(constructor).md b/docs/development/core/server/kibana-plugin-server.router.(constructor).md index 26048a603c9f6b..5f8e1e5e293ab7 100644 --- a/docs/development/core/server/kibana-plugin-server.router.(constructor).md +++ b/docs/development/core/server/kibana-plugin-server.router.(constructor).md @@ -16,5 +16,5 @@ constructor(path: string); | Parameter | Type | Description | | --- | --- | --- | -| path | string | a router path, set as the very first path segment for all registered routes. | +| path | string | | diff --git a/docs/development/core/server/kibana-plugin-server.router.md b/docs/development/core/server/kibana-plugin-server.router.md index a5eb1c66c4f0c9..59a0a22ec7b5e1 100644 --- a/docs/development/core/server/kibana-plugin-server.router.md +++ b/docs/development/core/server/kibana-plugin-server.router.md @@ -23,7 +23,6 @@ export declare class Router | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [path](./kibana-plugin-server.router.path.md) | | string | | -| [routes](./kibana-plugin-server.router.routes.md) | | Array<Readonly<RouterRoute>> | | ## Methods diff --git a/docs/development/core/server/kibana-plugin-server.router.routes.md b/docs/development/core/server/kibana-plugin-server.router.routes.md deleted file mode 100644 index c825bfe72d236d..00000000000000 --- a/docs/development/core/server/kibana-plugin-server.router.routes.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [routes](./kibana-plugin-server.router.routes.md) - -## Router.routes property - -Signature: - -```typescript -routes: Array>; -``` diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index bb52fa1fa38df9..d676a67b734d8d 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -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; @@ -97,12 +102,35 @@ function createRawRequestMock(customization: DeepPartial = {}) { ) as Request; } -function createRawResponseToolkitMock(customization: DeepPartial = {}) { - return merge({}, customization) as ResponseToolkit; -} +const createResponseFactoryMock = (): jest.Mocked => ({ + 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 => ({ + 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, }; diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 83e569e8752c5a..f82c42317edb21 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -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( - 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( - 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(''); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d90fb880a581c2..bf529ea7875d2d 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -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( @@ -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); - - 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); + authenticate: adoptToHapiAuthFormat( + fn, + this.log, + (req, { state, requestHeaders, responseHeaders }) => { + this.authState.set(req, state); + + if (responseHeaders) { + this.authResponseHeaders.set(req, responseHeaders); + } + + if (requestHeaders) { + this.authRequestHeaders.set(req, requestHeaders); + // we mutate headers only for the backward compatibility with the legacy platform. + // where some plugin read directly from headers to identify whether a user is authenticated. + Object.assign(req.headers, requestHeaders); + } } - }), + ), })); this.server.auth.strategy('session', 'login'); diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index b4e509c8a05a5a..e3a62c27d6a55e 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -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 & { @@ -72,20 +70,11 @@ const createHttpServiceMock = () => { const createOnPreAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), - redirected: jest.fn(), - rejected: jest.fn(), + rewriteUrl: jest.fn(), }); const createAuthToolkitMock = (): jest.Mocked => ({ authenticated: jest.fn(), - redirected: jest.fn(), - rejected: jest.fn(), -}); - -const createOnPostAuthToolkitMock = (): jest.Mocked => ({ - 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, }; diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 4b2bb2b6b86bfc..4be92fe492b3e3 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -31,6 +31,7 @@ export { KibanaRequestRoute, KnownHeaders, LegacyRequest, + LifecycleResponseFactory, RedirectResponseOptions, RequestHandler, ResponseError, diff --git a/src/core/server/http/integration_tests/http_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts similarity index 100% rename from src/core/server/http/integration_tests/http_service.test.mocks.ts rename to src/core/server/http/integration_tests/core_service.test.mocks.ts diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts new file mode 100644 index 00000000000000..691b1d01687148 --- /dev/null +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -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; + 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( + 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( + 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( + 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; + 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; + 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, + }); + }); + }); +}); diff --git a/src/core/server/http/integration_tests/http_service.test.ts b/src/core/server/http/integration_tests/http_service.test.ts deleted file mode 100644 index 303b81a73da3cf..00000000000000 --- a/src/core/server/http/integration_tests/http_service.test.ts +++ /dev/null @@ -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; - 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( - 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( - 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( - 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; - 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; - 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; - 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); - }); - }); - }); -}); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts new file mode 100644 index 00000000000000..068e52ea34460e --- /dev/null +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -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( + 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( + 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' }); + }); +}); diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 1d3ee4643830d9..5f3976c7c1941c 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -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 [ diff --git a/src/core/server/http/lifecycle/auth.test.ts b/src/core/server/http/lifecycle/auth.test.ts deleted file mode 100644 index 36aef7fc73bac2..00000000000000 --- a/src/core/server/http/lifecycle/auth.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts index e95b7af6e44f6e..a89c17a0340beb 100644 --- a/src/core/server/http/lifecycle/auth.ts +++ b/src/core/server/http/lifecycle/auth.ts @@ -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 = {}): 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; + response: LifecycleResponseFactory, + toolkit: AuthToolkit +) => AuthResult | KibanaResponse | Promise; /** @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 { + 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 || {} }); - } - if (authResult.isRedirected(result)) { - return h.redirect(result.url).takeover(); + return responseToolkit.authenticated({ credentials: result.state || {} }); } - 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(); } }; } diff --git a/src/core/server/http/lifecycle/on_post_auth.test.ts b/src/core/server/http/lifecycle/on_post_auth.test.ts deleted file mode 100644 index 88e8fc91149b8d..00000000000000 --- a/src/core/server/http/lifecycle/on_post_auth.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/core/server/http/lifecycle/on_post_auth.ts b/src/core/server/http/lifecycle/on_post_auth.ts index 64d27bbe7c5c53..a7f4eb57c26947 100644 --- a/src/core/server/http/lifecycle/on_post_auth.ts +++ b/src/core/server/http/lifecycle/on_post_auth.ts @@ -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 = ( - request: KibanaRequest, - t: OnPostAuthToolkit -) => OnPostAuthResult | Promise; +export type OnPostAuthHandler = ( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPostAuthToolkit +) => OnPostAuthResult | KibanaResponse | Promise; 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 { + 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; - } - if (postAuthResult.isRedirected(result)) { - return h.redirect(result.url).takeover(); + return responseToolkit.continue; } - 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(); } }; } diff --git a/src/core/server/http/lifecycle/on_pre_auth.test.ts b/src/core/server/http/lifecycle/on_pre_auth.test.ts deleted file mode 100644 index bae7c9f16eb139..00000000000000 --- a/src/core/server/http/lifecycle/on_pre_auth.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/core/server/http/lifecycle/on_pre_auth.ts b/src/core/server/http/lifecycle/on_pre_auth.ts index 13b267b1486669..ad204044ec34d5 100644 --- a/src/core/server/http/lifecycle/on_pre_auth.ts +++ b/src/core/server/http/lifecycle/on_pre_auth.ts @@ -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; - }, - isRedirected(result: OnPreAuthResult): result is Redirected { - return result.type === ResultType.redirected; + return result && result.type === ResultType.next; }, - 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 = ( - request: KibanaRequest, - t: OnPreAuthToolkit -) => OnPreAuthResult | Promise; +export type OnPreAuthHandler = ( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreAuthToolkit +) => OnPreAuthResult | KibanaResponse | Promise; /** * @public @@ -108,38 +87,36 @@ export type OnPreAuthHandler = ( * @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 { - try { - const result = await fn(KibanaRequest.from(request), toolkit); + const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); - if (!preAuthResult.isValid(result)) { - throw new Error( - `Unexpected result from OnPreAuth. Expected OnPreAuthResult, but given: ${result}.` - ); + try { + 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(); } }; } diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index f9009949825ba3..5e2cbe5b7e1582 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -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'; diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index 100c4b0d660cd8..cd977e6b754ab5 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -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 { +export class KibanaResponse { 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; diff --git a/src/core/server/http/router/response_adapter.ts b/src/core/server/http/router/response_adapter.ts index d8208b4a34058d..89fdea1d67f67c 100644 --- a/src/core/server/http/router/response_adapter.ts +++ b/src/core/server/http/router/response_adapter.ts @@ -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 = {}) { 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) { + 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) { + 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) { - 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) { @@ -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) { 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; } } diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index a4d4b62e40203b..c175d5f8b4311b 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -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; + handler: ( + request: Request, + responseToolkit: ResponseToolkit, + log: Logger + ) => Promise>; } /** @@ -47,10 +52,8 @@ interface RouterRoute { * @public * */ export class Router { - public routes: Array> = []; - /** - * @param path - a router path, set as the very first path segment for all registered routes. - */ + private routes: Array> = []; + constructor(readonly path: string) {} /** diff --git a/src/core/server/index.ts b/src/core/server/index.ts index cc0e0dc51f318c..5a7882cb6bd9e0 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -78,6 +78,7 @@ export { IsAuthenticated, KibanaRequest, KibanaRequestRoute, + LifecycleResponseFactory, KnownHeaders, LegacyRequest, OnPreAuthHandler, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2f2f93b417487c..a743bb7719b567 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -25,9 +25,10 @@ import { Url } from 'url'; export type APICaller = (endpoint: string, clientParams: Record, options?: CallAPIOptions) => Promise; // 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; +export type AuthenticationHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: AuthToolkit) => AuthResult | KibanaResponse | Promise; // @public export type AuthHeaders = Record; @@ -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 | Buffer | Stream>; - accepted: (payload?: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; - noContent: (options?: HttpResponseOptions) => KibanaResponse; custom: (payload: string | Error | Record | 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 | Buffer | Stream>; badRequest: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; unauthorized: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; forbidden: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; notFound: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; conflict: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; - internal: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + internalError: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + customError: (error: ResponseError, options: CustomHttpResponseOptions) => KibanaResponse; + redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; + ok: (payload: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + accepted: (payload?: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + noContent: (options?: HttpResponseOptions) => KibanaResponse; }; // 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; 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 = (request: KibanaRequest, t: OnPostAuthToolkit) => OnPostAuthResult | Promise; +export type OnPostAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPostAuthToolkit) => OnPostAuthResult | KibanaResponse | Promise; // @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 = (request: KibanaRequest, t: OnPreAuthToolkit) => OnPreAuthResult | Promise; +export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit) => OnPreAuthResult | KibanaResponse | Promise; // @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

(route: RouteConfig, handler: RequestHandler): void; get

(route: RouteConfig, handler: RequestHandler): void; + // Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts + // // @internal getRoutes(): Readonly[]; // (undocumented) readonly path: string; post

(route: RouteConfig, handler: RequestHandler): void; put

(route: RouteConfig, handler: RequestHandler): void; - // Warning: (ae-forgotten-export) The symbol "RouterRoute" needs to be exported by the entry point index.d.ts - // - // (undocumented) - routes: Array>; } // @public (undocumented) @@ -1029,7 +1021,6 @@ export interface SessionStorageFactory { // 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 diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 532c47b7d12ee6..ecd196a5a5a7b3 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -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')); diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index b14201bdff2f6e..88b6b5cd899ca3 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -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(); diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 335beca4c4b6be..5f45ca0f0de753 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -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(mockResponse.unauthorized).toHaveBeenCalledTimes(1); + const [[error]] = mockResponse.unauthorized.mock.calls; - expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1); - const [[error]] = mockAuthToolkit.rejected.mock.calls; - expect(error.message).toBe('Unauthorized'); - expect(getErrorStatusCode(error)).toBe(401); + expect(error).toBeUndefined(); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); - expect(mockAuthToolkit.redirected).not.toHaveBeenCalled(); + expect(mockResponse.redirected).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 14acfd006e6ef2..5652a3ccd9c8de 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -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.');