From dd03c4f3eba4b013cf1512c6b94160e333e60359 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 20 Feb 2020 15:20:53 +0100 Subject: [PATCH 1/3] Implement HTTP Authentication provider and allow `ApiKey` authentication by default. --- .../routes/api/__fixtures__/authc_mock.ts | 41 +- x-pack/plugins/case/server/services/index.ts | 10 +- .../authentication/authenticator.test.ts | 64 +- .../server/authentication/authenticator.ts | 13 + .../get_http_authentication_scheme.test.ts | 58 ++ .../get_http_authentication_scheme.ts | 21 + .../server/authentication/index.mock.ts | 3 +- .../server/authentication/index.test.ts | 2 +- .../security/server/authentication/index.ts | 1 + .../authentication/providers/base.mock.ts | 31 +- .../server/authentication/providers/base.ts | 1 + .../authentication/providers/basic.test.ts | 130 ++-- .../server/authentication/providers/basic.ts | 67 +- .../authentication/providers/http.test.ts | 280 +++++++++ .../server/authentication/providers/http.ts | 132 ++++ .../server/authentication/providers/index.ts | 1 + .../authentication/providers/kerberos.test.ts | 443 ++++++------- .../authentication/providers/kerberos.ts | 55 +- .../authentication/providers/oidc.test.ts | 306 +++++---- .../server/authentication/providers/oidc.ts | 54 +- .../authentication/providers/pki.test.ts | 132 +--- .../server/authentication/providers/pki.ts | 48 +- .../authentication/providers/saml.test.ts | 587 +++++++++--------- .../server/authentication/providers/saml.ts | 51 +- .../authentication/providers/token.test.ts | 397 ++++++------ .../server/authentication/providers/token.ts | 59 +- x-pack/plugins/security/server/config.test.ts | 248 +++++--- x-pack/plugins/security/server/config.ts | 41 +- x-pack/plugins/security/server/index.ts | 11 + x-pack/plugins/security/server/plugin.test.ts | 2 + .../routes/authentication/basic.test.ts | 18 + .../server/routes/authentication/basic.ts | 13 +- .../server/routes/authentication/index.ts | 9 +- .../api_integration/apis/security/session.ts | 8 +- 34 files changed, 1809 insertions(+), 1528 deletions(-) create mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts create mode 100644 x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts create mode 100644 x-pack/plugins/security/server/authentication/providers/http.test.ts create mode 100644 x-pack/plugins/security/server/authentication/providers/http.ts diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 94ce9627b9ac6..17a2518482637 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -3,33 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Authentication } from '../../../../../security/server'; +import { AuthenticatedUser } from '../../../../../security/server'; +import { securityMock } from '../../../../../security/server/mocks'; -const getCurrentUser = jest.fn().mockReturnValue({ - username: 'awesome', - full_name: 'Awesome D00d', -}); -const getCurrentUserThrow = jest.fn().mockImplementation(() => { - throw new Error('Bad User - the user is not authenticated'); -}); +function createAuthenticationMock({ + currentUser, +}: { currentUser?: AuthenticatedUser | null } = {}) { + const { authc } = securityMock.createSetup(); + authc.getCurrentUser.mockReturnValue( + currentUser !== undefined + ? currentUser + : ({ username: 'awesome', full_name: 'Awesome D00d' } as AuthenticatedUser) + ); + return authc; +} export const authenticationMock = { - create: (): jest.Mocked => ({ - login: jest.fn(), - createAPIKey: jest.fn(), - getCurrentUser, - invalidateAPIKey: jest.fn(), - isAuthenticated: jest.fn(), - logout: jest.fn(), - getSessionInfo: jest.fn(), - }), - createInvalid: (): jest.Mocked => ({ - login: jest.fn(), - createAPIKey: jest.fn(), - getCurrentUser: getCurrentUserThrow, - invalidateAPIKey: jest.fn(), - isAuthenticated: jest.fn(), - logout: jest.fn(), - getSessionInfo: jest.fn(), - }), + create: () => createAuthenticationMock(), + createInvalid: () => createAuthenticationMock({ currentUser: null }), }; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index d6d4bd606676c..e6416e268e30b 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -149,14 +149,8 @@ export class CaseService { } }, getUser: async ({ request, response }: GetUserArgs) => { - let user; - try { - this.log.debug(`Attempting to authenticate a user`); - user = await authentication!.getCurrentUser(request); - } catch (error) { - this.log.debug(`Error on GET user: ${error}`); - throw error; - } + this.log.debug(`Attempting to authenticate a user`); + const user = authentication!.getCurrentUser(request); if (!user) { this.log.debug(`Error on GET user: Bad User`); throw new Error('Bad User - the user is not authenticated'); diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 65874ba3a461e..16803ef8503da 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -5,6 +5,7 @@ */ jest.mock('./providers/basic'); +jest.mock('./providers/saml'); import Boom from 'boom'; import { duration, Duration } from 'moment'; @@ -23,15 +24,25 @@ import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenti import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider } from './providers'; -function getMockOptions(config: Partial = {}) { +function getMockOptions({ + session, + providers, +}: { + session?: AuthenticatorOptions['config']['session']; + providers?: string[]; +} = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), config: { - session: { idleTimeout: null, lifespan: null }, - authc: { providers: [], oidc: {}, saml: {} }, - ...config, + session: { idleTimeout: null, lifespan: null, ...(session || {}) }, + authc: { + providers: providers || [], + oidc: {}, + saml: {}, + http: { enabled: true, autoSchemesEnabled: true, schemes: [] }, + }, }, sessionStorageFactory: sessionStorageMock.createFactory(), }; @@ -55,20 +66,13 @@ describe('Authenticator', () => { describe('initialization', () => { it('fails if authentication providers are not configured.', () => { - const mockOptions = getMockOptions({ - authc: { providers: [], oidc: {}, saml: {} }, - }); - expect(() => new Authenticator(mockOptions)).toThrowError( + expect(() => new Authenticator(getMockOptions())).toThrowError( 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' ); }); it('fails if configured authentication provider is not known.', () => { - const mockOptions = getMockOptions({ - authc: { providers: ['super-basic'], oidc: {}, saml: {} }, - }); - - expect(() => new Authenticator(mockOptions)).toThrowError( + expect(() => new Authenticator(getMockOptions({ providers: ['super-basic'] }))).toThrowError( 'Unsupported authentication provider name: super-basic.' ); }); @@ -80,9 +84,7 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ - authc: { providers: ['basic'], oidc: {}, saml: {} }, - }); + mockOptions = getMockOptions({ providers: ['basic'] }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { @@ -232,9 +234,7 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ - authc: { providers: ['basic'], oidc: {}, saml: {} }, - }); + mockOptions = getMockOptions({ providers: ['basic'] }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { @@ -377,7 +377,7 @@ describe('Authenticator', () => { idleTimeout: duration(3600 * 24), lifespan: null, }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -416,7 +416,7 @@ describe('Authenticator', () => { idleTimeout: duration(hr * 2), lifespan: duration(hr * 8), }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -468,7 +468,7 @@ describe('Authenticator', () => { idleTimeout: null, lifespan, }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -718,9 +718,7 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ - authc: { providers: ['basic'], oidc: {}, saml: {} }, - }); + mockOptions = getMockOptions({ providers: ['basic'] }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { @@ -809,9 +807,7 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ - authc: { providers: ['basic'], oidc: {}, saml: {} }, - }); + mockOptions = getMockOptions({ providers: ['basic'] }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -851,4 +847,16 @@ describe('Authenticator', () => { expect(sessionInfo).toBe(null); }); }); + + describe('`isProviderEnabled` method', () => { + it('returns `true` only if specified provider is enabled', () => { + let authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('saml')).toBe(false); + + authenticator = new Authenticator(getMockOptions({ providers: ['basic', 'saml'] })); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('saml')).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 3ab49d3c5b124..5d99fe5ecbf06 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -27,6 +27,7 @@ import { TokenAuthenticationProvider, OIDCAuthenticationProvider, PKIAuthenticationProvider, + HTTPAuthenticationProvider, isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; @@ -105,6 +106,7 @@ const providerMap = new Map< [TokenAuthenticationProvider.type, TokenAuthenticationProvider], [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + [HTTPAuthenticationProvider.type, HTTPAuthenticationProvider], ]); function assertRequest(request: KibanaRequest) { @@ -191,6 +193,7 @@ export class Authenticator { client: this.options.clusterClient, logger: this.options.loggers.get('tokens'), }), + isProviderEnabled: this.isProviderEnabled.bind(this), }; const authProviders = this.options.config.authc.providers; @@ -206,6 +209,8 @@ export class Authenticator { ? (this.options.config.authc as Record)[providerType] : undefined; + this.logger.debug(`Enabling "${providerType}" authentication provider.`); + return [ providerType, instantiateProvider( @@ -385,6 +390,14 @@ export class Authenticator { return null; } + /** + * Checks whether specified provider type is currently enabled. + * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). + */ + isProviderEnabled(providerType: string) { + return this.providers.has(providerType); + } + /** * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). * @param sessionValue Current session value. diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts new file mode 100644 index 0000000000000..6a63634394ec0 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks'; + +import { getHTTPAuthenticationScheme } from './get_http_authentication_scheme'; + +describe('getHTTPAuthenticationScheme', () => { + it('returns `null` if request does not have authorization header', () => { + expect(getHTTPAuthenticationScheme(httpServerMock.createKibanaRequest())).toBeNull(); + }); + + it('returns `null` if authorization header value isn not a string', () => { + expect( + getHTTPAuthenticationScheme( + httpServerMock.createKibanaRequest({ + headers: { authorization: ['Basic xxx', 'Bearer xxx'] as any }, + }) + ) + ).toBeNull(); + }); + + it('returns `null` if authorization header value is an empty string', () => { + expect( + getHTTPAuthenticationScheme( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).toBeNull(); + }); + + it('returns only scheme portion of the authorization header value in lower case', () => { + const headerValueAndSchemeMap = [ + ['Basic xxx', 'basic'], + ['Basic xxx yyy', 'basic'], + ['basic xxx', 'basic'], + ['basic', 'basic'], + // We don't trim leading whitespaces in scheme. + [' Basic xxx', ''], + ['Negotiate xxx', 'negotiate'], + ['negotiate xxx', 'negotiate'], + ['negotiate', 'negotiate'], + ['ApiKey xxx', 'apikey'], + ['apikey xxx', 'apikey'], + ['Api Key xxx', 'api'], + ]; + + for (const [authorization, scheme] of headerValueAndSchemeMap) { + expect( + getHTTPAuthenticationScheme( + httpServerMock.createKibanaRequest({ headers: { authorization } }) + ) + ).toBe(scheme); + } + }); +}); diff --git a/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts new file mode 100644 index 0000000000000..b9c53f34dbcab --- /dev/null +++ b/x-pack/plugins/security/server/authentication/get_http_authentication_scheme.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 } from '../../../../../src/core/server'; + +/** + * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. + * https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes + * @param request Request instance to extract authentication scheme for. + */ +export function getHTTPAuthenticationScheme(request: KibanaRequest) { + const authorizationHeaderValue = request.headers.authorization; + if (!authorizationHeaderValue || typeof authorizationHeaderValue !== 'string') { + return null; + } + + return authorizationHeaderValue.split(/\s+/)[0].toLowerCase(); +} diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 77f1f9e45aea7..c634e2c80c299 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -9,11 +9,12 @@ import { Authentication } from '.'; export const authenticationMock = { create: (): jest.Mocked => ({ login: jest.fn(), + logout: jest.fn(), + isProviderEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), invalidateAPIKey: jest.fn(), isAuthenticated: jest.fn(), - logout: jest.fn(), getSessionInfo: jest.fn(), }), }; diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 3727b1fc13dac..aaf3fc357352e 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -61,7 +61,7 @@ describe('setupAuthentication()', () => { lifespan: null, }, cookieName: 'my-sid-cookie', - authc: { providers: ['basic'] }, + authc: { providers: ['basic'], http: { enabled: true } }, }), true ); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 467afe0034025..189babbc6bfe6 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -169,6 +169,7 @@ export async function setupAuthentication({ login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), getSessionInfo: authenticator.getSessionInfo.bind(authenticator), + isProviderEnabled: authenticator.isProviderEnabled.bind(authenticator), getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index a659786f4aeff..5a65c95fd3269 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; -import { ScopedClusterClient } from '../../../../../../src/core/server'; -import { Tokens } from '../tokens'; import { loggingServiceMock, httpServiceMock, @@ -17,34 +14,7 @@ export type MockAuthenticationProviderOptions = ReturnType< typeof mockAuthenticationProviderOptions >; -export type MockAuthenticationProviderOptionsWithJest = ReturnType< - typeof mockAuthenticationProviderOptionsWithJest ->; - -export function mockScopedClusterClient( - client: MockAuthenticationProviderOptions['client'], - requestMatcher: sinon.SinonMatcher = sinon.match.any -) { - const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient); - client.asScoped.withArgs(requestMatcher).returns(scopedClusterClient); - return scopedClusterClient; -} - export function mockAuthenticationProviderOptions() { - const logger = loggingServiceMock.create().get(); - const basePath = httpServiceMock.createSetupContract().basePath; - basePath.get.mockReturnValue('/base-path'); - - return { - client: { callAsInternalUser: sinon.stub(), asScoped: sinon.stub(), close: sinon.stub() }, - logger, - basePath, - tokens: sinon.createStubInstance(Tokens), - }; -} - -// Will be renamed to mockAuthenticationProviderOptions as soon as we migrate all providers tests to Jest. -export function mockAuthenticationProviderOptionsWithJest() { const basePath = httpServiceMock.createSetupContract().basePath; basePath.get.mockReturnValue('/base-path'); @@ -53,5 +23,6 @@ export function mockAuthenticationProviderOptionsWithJest() { logger: loggingServiceMock.create().get(), basePath, tokens: { refresh: jest.fn(), invalidate: jest.fn() }, + isProviderEnabled: jest.fn(), }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index a40732768810d..e98bda51178ce 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -25,6 +25,7 @@ export interface AuthenticationProviderOptions { client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; + isProviderEnabled: (provider: string) => boolean; } /** diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index f9c665d6cea48..dfa9639fa3583 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; - -import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { mockAuthenticationProviderOptions, mockScopedClusterClient } from './base.mock'; +import { mockAuthenticationProviderOptions } from './base.mock'; import { BasicAuthenticationProvider } from './basic'; @@ -30,15 +28,22 @@ describe('BasicAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.login( - httpServerMock.createKibanaRequest(), + httpServerMock.createKibanaRequest({ headers: {} }), credentials ); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toEqual({ authorization }); @@ -46,17 +51,24 @@ describe('BasicAuthenticationProvider', () => { }); it('fails if user cannot be retrieved during login attempt', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const credentials = { username: 'user', password: 'password' }; const authorization = generateAuthorizationHeader(credentials.username, credentials.password); const authenticationError = new Error('Some error'); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.login(request, credentials); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); @@ -97,67 +109,70 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.notHandled()).toBe(true); }); - it('succeeds if only `authorization` header is available.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: generateAuthorizationHeader('user', 'password') }, - }); - const user = mockAuthenticatedUser(); - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + it('does not handle authentication via `authorization` header.', async () => { + const authorization = generateAuthorizationHeader('user', 'password'); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); const authenticationResult = await provider.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + expect(authenticationResult.notHandled()).toBe(true); + }); - // Session state and authHeaders aren't returned for header-based auth. - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); + it('does not handle authentication via `authorization` header even if state contains valid credentials.', async () => { + const authorization = generateAuthorizationHeader('user', 'password'); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + + const authenticationResult = await provider.authenticate(request, { authorization }); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + expect(authenticationResult.notHandled()).toBe(true); }); it('succeeds if only state is available.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, { authorization }); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual(user); expect(authenticationResult.state).toBeUndefined(); expect(authenticationResult.authHeaders).toEqual({ authorization }); }); - it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer ***' }, - }); - const authorization = generateAuthorizationHeader('user', 'password'); - - const authenticationResult = await provider.authenticate(request, { authorization }); - - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Bearer ***'); - expect(authenticationResult.notHandled()).toBe(true); - }); - it('fails if state contains invalid credentials.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = generateAuthorizationHeader('user', 'password'); const authenticationError = new Error('Forbidden'); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, { authorization }); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); @@ -165,27 +180,6 @@ describe('BasicAuthenticationProvider', () => { expect(authenticationResult.authHeaders).toBeUndefined(); expect(authenticationResult.error).toBe(authenticationError); }); - - it('authenticates only via `authorization` header even if state is available.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: generateAuthorizationHeader('user', 'password') }, - }); - const user = mockAuthenticatedUser(); - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authorizationInState = generateAuthorizationHeader('user1', 'password2'); - const authenticationResult = await provider.authenticate(request, { - authorization: authorizationInState, - }); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); - }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index a8e4e8705a7a8..75a1439b9b9e5 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -8,6 +8,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { canRedirectRequest } from '../can_redirect_request'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { BaseAuthenticationProvider } from './base'; /** @@ -75,29 +76,25 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - // try header-based auth - const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; + if (getHTTPAuthenticationScheme(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); } - let authenticationResult = headerAuthResult; - if (authenticationResult.notHandled() && state) { - authenticationResult = await this.authenticateViaState(request, state); - } else if (authenticationResult.notHandled() && canRedirectRequest(request)) { - // If we couldn't handle authentication let's redirect user to the login page. - const nextURL = encodeURIComponent( - `${this.options.basePath.get(request)}${request.url.path}` - ); - authenticationResult = AuthenticationResult.redirectTo( - `${this.options.basePath.get(request)}/login?next=${nextURL}` + if (state) { + return await this.authenticateViaState(request, state); + } + + // If state isn't present let's redirect user to the login page. + if (canRedirectRequest(request)) { + this.logger.debug('Redirecting request to Login page.'); + const basePath = this.options.basePath.get(request); + return AuthenticationResult.redirectTo( + `${basePath}/login?next=${encodeURIComponent(`${basePath}${request.url.path}`)}` ); } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -113,40 +110,6 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { ); } - /** - * Validates whether request contains `Basic ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: KibanaRequest) { - this.logger.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - this.logger.debug('Authorization header is not presented.'); - return { authenticationResult: AuthenticationResult.notHandled() }; - } - - const authenticationSchema = authorization.split(/\s+/)[0]; - if (authenticationSchema.toLowerCase() !== 'basic') { - this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); - return { - authenticationResult: AuthenticationResult.notHandled(), - headerNotRecognized: true, - }; - } - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated via header.'); - return { authenticationResult: AuthenticationResult.succeeded(user) }; - } catch (err) { - this.logger.debug(`Failed to authenticate request via header: ${err.message}`); - return { authenticationResult: AuthenticationResult.failed(err) }; - } - } - /** * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts new file mode 100644 index 0000000000000..bd70ad9537976 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; + +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { HTTPAuthenticationProvider } from './http'; + +describe('HTTPAuthenticationProvider', () => { + let mockOptions: MockAuthenticationProviderOptions; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + }); + + it('throws if `schemes` are not specified', () => { + const providerOptions = mockAuthenticationProviderOptions(); + + expect(() => new HTTPAuthenticationProvider(providerOptions)).toThrowError( + 'Supported schemes should be specified' + ); + expect(() => new HTTPAuthenticationProvider(providerOptions, {})).toThrowError( + 'Supported schemes should be specified' + ); + expect(() => new HTTPAuthenticationProvider(providerOptions, { schemes: [] })).toThrowError( + 'Supported schemes should be specified' + ); + + expect( + () => + new HTTPAuthenticationProvider(providerOptions, { schemes: [], autoSchemesEnabled: false }) + ).toThrowError('Supported schemes should be specified'); + }); + + describe('`login` method', () => { + it('does not handle login', async () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled: true, + schemes: ['apikey'], + }); + const authenticationResult = await provider.login(); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + + expect(authenticationResult.notHandled()).toBe(true); + }); + }); + + describe('`authenticate` method', () => { + const testCasesToNotHandle = [ + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['basic'], + header: 'Bearer xxx', + }, + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['bearer'], + header: 'Basic xxx', + }, + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['basic', 'apikey'], + header: 'Bearer xxx', + }, + { + autoSchemesEnabled: true, + isProviderEnabled: () => false, + schemes: ['basic', 'apikey'], + header: 'Bearer xxx', + }, + { + autoSchemesEnabled: true, + isProviderEnabled: (provider: string) => provider === 'basic', + schemes: ['basic'], + header: 'Bearer xxx', + }, + { + autoSchemesEnabled: true, + isProviderEnabled: () => true, + schemes: [], + header: 'ApiKey xxx', + }, + ]; + + const testCasesToHandle = [ + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['basic'], + header: 'Basic xxx', + }, + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['bearer'], + header: 'Bearer xxx', + }, + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['basic', 'apikey'], + header: 'ApiKey xxx', + }, + { + autoSchemesEnabled: false, + isProviderEnabled: () => false, + schemes: ['some-weird-scheme'], + header: 'some-weird-scheme xxx', + }, + ...['saml', 'oidc', 'pki', 'kerberos', 'token'].map(bearerProviderType => ({ + autoSchemesEnabled: true, + isProviderEnabled: (providerType: string) => providerType === bearerProviderType, + schemes: ['apikey'], + header: 'Bearer xxx', + })), + { + autoSchemesEnabled: true, + isProviderEnabled: (provider: string) => provider === 'basic', + schemes: ['apikey'], + header: 'Basic xxx', + }, + { + autoSchemesEnabled: true, + isProviderEnabled: () => true, + schemes: [], + header: 'Bearer xxx', + }, + ]; + + it('does not handle authentication for requests without `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled: true, + schemes: ['apikey'], + }); + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { authorization: '' } }); + + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled: true, + schemes: ['apikey'], + }); + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { + for (const { + isProviderEnabled, + autoSchemesEnabled, + schemes, + header, + } of testCasesToNotHandle) { + const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); + + mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled, + schemes, + }); + const authenticationResult = await provider.authenticate(request); + + expect(request.headers.authorization).toBe(header); + expect(authenticationResult.notHandled()).toBe(true); + } + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('succeeds if authentication via `authorization` header with supported scheme succeeds.', async () => { + const user = mockAuthenticatedUser(); + for (const { isProviderEnabled, autoSchemesEnabled, schemes, header } of testCasesToHandle) { + const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.asScoped.mockClear(); + + mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled, + schemes, + }); + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: header }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'http' }); + expect(authenticationResult.state).toBeUndefined(); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(request.headers.authorization).toBe(header); + } + }); + + it('fails if authentication via `authorization` header with supported scheme fails.', async () => { + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + for (const { isProviderEnabled, autoSchemesEnabled, schemes, header } of testCasesToHandle) { + const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.asScoped.mockClear(); + + mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled, + schemes, + }); + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: header }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(authenticationResult.failed()).toBe(true); + expect(authenticationResult.error).toBe(failureReason); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(request.headers.authorization).toBe(header); + } + }); + }); + + describe('`logout` method', () => { + it('does not handle logout', async () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + enabled: true, + autoSchemesEnabled: true, + schemes: ['apikey'], + }); + const authenticationResult = await provider.logout(); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + + expect(authenticationResult.notHandled()).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts new file mode 100644 index 0000000000000..6905c35a9977d --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; + +interface HTTPAuthenticationProviderOptions { + enabled: boolean; + autoSchemesEnabled: boolean; + schemes: string[]; +} + +/** + * Provider that supports request authentication via forwarding `Authorization` HTTP header to Elasticsearch. + */ +export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Type of the provider. + */ + static readonly type = 'http'; + + /** + * Set of the schemes (`Basic`, `Bearer` etc.) that provider expects to see within `Authorization` + * HTTP header while authenticating request. + */ + private readonly supportedSchemes: Set; + + /** + * Indicates whether we should allow schemes that other providers use to authenticate with + * Elasticsearch. + */ + private readonly autoSchemesEnabled: boolean; + + constructor( + protected readonly options: Readonly, + proxyOptions?: Readonly> + ) { + super(options); + + this.supportedSchemes = new Set( + (proxyOptions?.schemes ?? []).map(scheme => scheme.toLowerCase()) + ); + this.autoSchemesEnabled = proxyOptions?.autoSchemesEnabled ?? false; + + if (this.supportedSchemes.size === 0 && !this.autoSchemesEnabled) { + throw new Error('Supported schemes should be specified'); + } + } + + /** + * NOT SUPPORTED. + */ + public async login() { + this.logger.debug('Login is not supported.'); + return AuthenticationResult.notHandled(); + } + + /** + * Performs request authentication using provided `Authorization` HTTP headers. + * @param request Request instance. + */ + public async authenticate(request: KibanaRequest) { + this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); + + const authenticationScheme = getHTTPAuthenticationScheme(request); + if (authenticationScheme == null) { + this.logger.debug('Authorization header is not presented.'); + return AuthenticationResult.notHandled(); + } + + if (!this.isSchemeSupported(authenticationScheme)) { + this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + return AuthenticationResult.notHandled(); + } + + try { + const user = await this.getUser(request); + this.logger.debug( + `Request to ${request.url.path} has been authenticated via authorization header with "${authenticationScheme}" scheme.` + ); + return AuthenticationResult.succeeded(user); + } catch (err) { + this.logger.debug( + `Failed to authenticate request to ${request.url.path} via authorization header with "${authenticationScheme}" scheme: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * NOT SUPPORTED. + */ + public async logout() { + this.logger.debug('Logout is not supported.'); + return DeauthenticationResult.notHandled(); + } + + /** + * Checks whether specified scheme should be supported by the provider based on the explicitly + * specified schemes in Kibana configuration or currently enabled authentication providers (if + * `xpack.security.authc.http.autoSchemesEnabled` is set to `true`). + * @param scheme + */ + private isSchemeSupported(scheme: string) { + const isSchemeSupported = this.supportedSchemes.has(scheme); + if (isSchemeSupported || !this.autoSchemesEnabled) { + return isSchemeSupported; + } + + if (scheme === 'basic') { + return this.options.isProviderEnabled('basic'); + } + + if (scheme === 'bearer') { + return ( + this.options.isProviderEnabled('saml') || + this.options.isProviderEnabled('oidc') || + this.options.isProviderEnabled('pki') || + this.options.isProviderEnabled('kerberos') || + this.options.isProviderEnabled('token') + ); + } + + return false; + } +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index 1ec6dfb67a81d..cd8f5a70c64e3 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -15,3 +15,4 @@ export { SAMLAuthenticationProvider, isSAMLRequestQuery, SAMLLoginStep } from '. export { TokenAuthenticationProvider } from './token'; export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; export { PKIAuthenticationProvider } from './pki'; +export { HTTPAuthenticationProvider } from './http'; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index e4b4df3feeae2..020a5159d521e 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -6,18 +6,13 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; -import sinon from 'sinon'; -import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { - MockAuthenticationProviderOptions, - mockAuthenticationProviderOptions, - mockScopedClusterClient, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; import { KerberosAuthenticationProvider } from './kerberos'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server/elasticsearch'; describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; @@ -28,9 +23,22 @@ describe('KerberosAuthenticationProvider', () => { }); describe('`authenticate` method', () => { - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Basic some:credentials' }, + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, }); const tokenPair = { accessToken: 'some-valid-token', @@ -39,40 +47,49 @@ describe('KerberosAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.notCalled(mockOptions.client.asScoped); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves({}); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, null); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle requests if backend does not support Kerberos.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, null); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.notHandled()).toBe(true); }); @@ -80,36 +97,45 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ - headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, - }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects( - ElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) - ) - ); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, null); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); @@ -117,9 +143,10 @@ describe('KerberosAuthenticationProvider', () => { it('fails if request authentication is failed with non-401 error.', async () => { const request = httpServerMock.createKibanaRequest(); - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(new errors.ServiceUnavailable()); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, null); @@ -134,24 +161,26 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: 'Bearer some-token' }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.succeeded()).toBe(true); @@ -170,14 +199,10 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - mockOptions.client.callAsInternalUser.withArgs('shield.getAccessToken').resolves({ + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'some-token', refresh_token: 'some-refresh-token', kerberos_authentication_response_token: 'response-token', @@ -185,11 +210,16 @@ describe('KerberosAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: 'Bearer some-token' }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.succeeded()).toBe(true); @@ -214,17 +244,13 @@ describe('KerberosAuthenticationProvider', () => { body: { error: { header: { 'WWW-Authenticate': 'Negotiate response-token' } } }, }) ); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -244,17 +270,13 @@ describe('KerberosAuthenticationProvider', () => { body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -270,17 +292,13 @@ describe('KerberosAuthenticationProvider', () => { }); const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -294,24 +312,26 @@ describe('KerberosAuthenticationProvider', () => { }); const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } - ); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: 'Bearer some-token' }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, + }); expect(request.headers.authorization).toBe('negotiate spnego'); expect(authenticationResult.failed()).toBe(true); @@ -321,19 +341,24 @@ describe('KerberosAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ authorization }); @@ -346,27 +371,33 @@ describe('KerberosAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())); - - mockOptions.tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + return mockScopedClusterClient; + } + + if (scopeableRequest?.headers.authorization === 'Bearer newfoo') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + return mockScopedClusterClient; + } + + throw new Error('Unexpected call'); + }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer newfoo' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'newfoo', + refreshToken: 'newbar', + }); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); @@ -376,120 +407,58 @@ describe('KerberosAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; const failureReason = new errors.InternalServerError('Token is not valid!'); - const scopedClusterClient = mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ); - scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').rejects(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Bearer ${tokenPair.accessToken}` }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); - sinon.assert.neverCalledWith(scopedClusterClient.callAsCurrentUser, 'shield.getAccessToken'); }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects( - ElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) - ) - ); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); }); - - it('succeeds if `authorization` contains a valid token.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-valid-token' }, - }); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.state).toBeUndefined(); - }); - - it('fails if token from `authorization` header is rejected.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-invalid-token' }, - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-invalid-token' }, - }); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); }); describe('`logout` method', () => { @@ -502,7 +471,7 @@ describe('KerberosAuthenticationProvider', () => { deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(mockOptions.tokens.invalidate); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); it('fails if `tokens.invalidate` fails', async () => { @@ -510,12 +479,12 @@ describe('KerberosAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -528,12 +497,12 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index b8e3b7bc23790..9ae35cfe75901 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -12,27 +12,15 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider } from './base'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { Tokens, TokenPair } from '../tokens'; +import { BaseAuthenticationProvider } from './base'; /** * The state supported by the provider. */ type ProviderState = TokenPair; -/** - * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. - * @param request Request instance to extract authentication scheme for. - */ -function getRequestAuthenticationScheme(request: KibanaRequest) { - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - return ''; - } - - return authorization.split(/\s+/)[0].toLowerCase(); -} - /** * Name of the `WWW-Authenticate` we parse out of Elasticsearch responses or/and return to the * client to initiate or continue negotiation. @@ -56,24 +44,15 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getRequestAuthenticationScheme(request); - if ( - authenticationScheme && - authenticationScheme !== 'negotiate' && - authenticationScheme !== 'bearer' - ) { + const authenticationScheme = getHTTPAuthenticationScheme(request); + if (authenticationScheme && authenticationScheme !== 'negotiate') { this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); return AuthenticationResult.notHandled(); } - let authenticationResult = AuthenticationResult.notHandled(); - if (authenticationScheme) { - // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. - authenticationResult = - authenticationScheme === 'bearer' - ? await this.authenticateWithBearerScheme(request) - : await this.authenticateWithNegotiateScheme(request); - } + let authenticationResult = authenticationScheme + ? await this.authenticateWithNegotiateScheme(request) + : AuthenticationResult.notHandled(); if (state && authenticationResult.notHandled()) { authenticationResult = await this.authenticateViaState(request, state); @@ -201,26 +180,6 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } } - /** - * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateWithBearerScheme(request: KibanaRequest) { - this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); - return AuthenticationResult.succeeded(user); - } catch (err) { - this.logger.debug( - `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` - ); - return AuthenticationResult.failed(err); - } - } - /** * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index dae3774955859..5a0480b59bab1 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -4,18 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; import Boom from 'boom'; -import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { - MockAuthenticationProviderOptions, - mockAuthenticationProviderOptions, - mockScopedClusterClient, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { KibanaRequest } from '../../../../../../src/core/server'; +import { ElasticsearchErrorHelpers, KibanaRequest } from '../../../../../../src/core/server'; import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; describe('OIDCAuthenticationProvider', () => { @@ -44,7 +39,7 @@ describe('OIDCAuthenticationProvider', () => { it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/security/oidc/callback' }); - mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ state: 'statevalue', nonce: 'noncevalue', redirect: @@ -62,7 +57,7 @@ describe('OIDCAuthenticationProvider', () => { loginHint: 'loginhint', }); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { iss: 'theissuer', login_hint: 'loginhint' }, }); @@ -92,9 +87,10 @@ describe('OIDCAuthenticationProvider', () => { it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => { const { request, attempt, expectedRedirectURI } = getMocks(); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcAuthenticate') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: 'some-token', + refresh_token: 'some-refresh-token', + }); const authenticationResult = await provider.login(request, attempt, { state: 'statevalue', @@ -102,8 +98,7 @@ describe('OIDCAuthenticationProvider', () => { nextURL: '/base-path/some-path', }); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.oidcAuthenticate', { body: { @@ -130,7 +125,7 @@ describe('OIDCAuthenticationProvider', () => { nextURL: '/base-path/some-path', }); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -148,7 +143,7 @@ describe('OIDCAuthenticationProvider', () => { nonce: 'noncevalue', }); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -163,7 +158,7 @@ describe('OIDCAuthenticationProvider', () => { const authenticationResult = await provider.login(request, attempt, {}); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.failed()).toBe(true); }); @@ -174,9 +169,7 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error( 'Failed to exchange code for Id Token using the Token Endpoint.' ); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcAuthenticate') - .returns(Promise.reject(failureReason)); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.login(request, attempt, { state: 'statevalue', @@ -184,8 +177,7 @@ describe('OIDCAuthenticationProvider', () => { nextURL: '/base-path/some-path', }); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.oidcAuthenticate', { body: { @@ -243,7 +235,7 @@ describe('OIDCAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ state: 'statevalue', nonce: 'noncevalue', redirect: @@ -256,7 +248,7 @@ describe('OIDCAuthenticationProvider', () => { const authenticationResult = await provider.authenticate(request, null); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); @@ -279,13 +271,11 @@ describe('OIDCAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcPrepare') - .returns(Promise.reject(failureReason)); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.authenticate(request, null); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); @@ -295,19 +285,26 @@ describe('OIDCAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ authorization }); @@ -315,9 +312,21 @@ describe('OIDCAuthenticationProvider', () => { expect(authenticationResult.state).toBeUndefined(); }); - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Basic some:credentials' }, + headers: { authorization: 'Bearer some-token' }, }); const authenticationResult = await provider.authenticate(request, { @@ -325,13 +334,13 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }); - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); expect(authenticationResult.notHandled()).toBe(true); }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-invalid-token', refreshToken: 'some-invalid-refresh-token', @@ -339,12 +348,19 @@ describe('OIDCAuthenticationProvider', () => { const authorization = `Bearer ${tokenPair.accessToken}`; const failureReason = new Error('Token is not valid!'); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -355,26 +371,34 @@ describe('OIDCAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + return mockScopedClusterClient; + } + + if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + return mockScopedClusterClient; + } + + throw new Error('Unexpected call'); + }); - mockOptions.tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ @@ -388,34 +412,45 @@ describe('OIDCAuthenticationProvider', () => { }); it('fails if token from the state is expired and refresh attempt failed too.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, message: 'Something is wrong with refresh token.', }; - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason); + mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(refreshFailureReason); }); it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ state: 'statevalue', nonce: 'noncevalue', redirect: @@ -426,18 +461,27 @@ describe('OIDCAuthenticationProvider', () => { '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); @@ -459,83 +503,34 @@ describe('OIDCAuthenticationProvider', () => { it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { 'kbn-xsrf': 'xsrf', authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( Boom.badRequest('Both access and refresh tokens are expired.') ); }); - - it('succeeds if `authorization` contains a valid token.', async () => { - const user = mockAuthenticatedUser(); - const authorization = 'Bearer some-valid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'oidc' }); - expect(authenticationResult.state).toBeUndefined(); - }); - - it('fails if token from `authorization` header is rejected.', async () => { - const authorization = 'Bearer some-invalid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - const failureReason = { statusCode: 401 }; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = mockAuthenticatedUser(); - const authorization = 'Bearer some-invalid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - const failureReason = { statusCode: 401 }; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); }); describe('`logout` method', () => { @@ -551,7 +546,7 @@ describe('OIDCAuthenticationProvider', () => { deauthenticateResult = await provider.logout(request, { nonce: 'x' }); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if OpenID Connect logout call fails.', async () => { @@ -560,17 +555,15 @@ describe('OIDCAuthenticationProvider', () => { const refreshToken = 'x-oidc-refresh-token'; const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcLogout') - .returns(Promise.reject(failureReason)); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -583,17 +576,15 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcLogout') - .resolves({ redirect: null }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -606,16 +597,19 @@ describe('OIDCAuthenticationProvider', () => { const accessToken = 'x-oidc-token'; const refreshToken = 'x-oidc-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.oidcLogout') - .resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/logout&id_token_hint=thehint', + }); const authenticationResult = await provider.logout(request, { accessToken, refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { + body: { token: accessToken, refresh_token: refreshToken }, + }); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index f13a2ec05231a..d6f4bf37001d3 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -10,6 +10,7 @@ import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { Tokens, TokenPair } from '../tokens'; import { AuthenticationProviderOptions, @@ -130,16 +131,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. - let { - authenticationResult, - headerNotRecognized, // eslint-disable-line prefer-const - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return authenticationResult; + if (getHTTPAuthenticationScheme(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); } - if (state && authenticationResult.notHandled()) { + let authenticationResult = AuthenticationResult.notHandled(); + if (state) { authenticationResult = await this.authenticateViaState(request, state); if ( authenticationResult.failed() && @@ -276,46 +274,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } } - /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: KibanaRequest) { - this.logger.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - this.logger.debug('Authorization header is not presented.'); - return { - authenticationResult: AuthenticationResult.notHandled(), - }; - } - - const authenticationSchema = authorization.split(/\s+/)[0]; - if (authenticationSchema.toLowerCase() !== 'bearer') { - this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); - return { - authenticationResult: AuthenticationResult.notHandled(), - headerNotRecognized: true, - }; - } - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated via header.'); - return { - authenticationResult: AuthenticationResult.succeeded(user), - }; - } catch (err) { - this.logger.debug(`Failed to authenticate request via header: ${err.message}`); - return { - authenticationResult: AuthenticationResult.failed(err), - }; - } - } - /** * Tries to extract an elasticsearch access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index a2dda88c4680c..6cee9f970d9b2 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -12,16 +12,10 @@ import { errors } from 'elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { - MockAuthenticationProviderOptionsWithJest, - mockAuthenticationProviderOptionsWithJest, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; import { PKIAuthenticationProvider } from './pki'; -import { - ElasticsearchErrorHelpers, - ScopedClusterClient, -} from '../../../../../../src/core/server/elasticsearch'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; import { Socket } from 'net'; import { getErrorStatusCode } from '../../errors'; @@ -64,18 +58,31 @@ function getMockSocket({ describe('PKIAuthenticationProvider', () => { let provider: PKIAuthenticationProvider; - let mockOptions: MockAuthenticationProviderOptionsWithJest; + let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptionsWithJest(); + mockOptions = mockAuthenticationProviderOptions(); provider = new PKIAuthenticationProvider(mockOptions); }); afterEach(() => jest.clearAllMocks()); describe('`authenticate` method', () => { - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + it('does not handle authentication via `authorization` header.', async () => { const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Basic some:credentials' }, + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, }); const state = { accessToken: 'some-valid-token', @@ -86,7 +93,7 @@ describe('PKIAuthenticationProvider', () => { expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(request.headers.authorization).toBe('Bearer some-token'); expect(authenticationResult.notHandled()).toBe(true); }); @@ -174,9 +181,7 @@ describe('PKIAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); const authenticationResult = await provider.authenticate(request); @@ -221,9 +226,7 @@ describe('PKIAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); const authenticationResult = await provider.authenticate(request); @@ -263,9 +266,7 @@ describe('PKIAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); const authenticationResult = await provider.authenticate(request, state); @@ -312,9 +313,7 @@ describe('PKIAuthenticationProvider', () => { .mockRejectedValueOnce(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())) // In response to a call with a new token. .mockResolvedValueOnce(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); const authenticationResult = await provider.authenticate(request, state); @@ -348,9 +347,7 @@ describe('PKIAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, state); @@ -398,9 +395,7 @@ describe('PKIAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); const authenticationResult = await provider.authenticate(request); @@ -435,9 +430,7 @@ describe('PKIAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, state); @@ -463,9 +456,7 @@ describe('PKIAuthenticationProvider', () => { const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, state); @@ -473,73 +464,6 @@ describe('PKIAuthenticationProvider', () => { expect(authenticationResult.error).toHaveProperty('status', 503); expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); - - it('succeeds if `authorization` contains a valid token.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-valid-token' }, - }); - - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - - const authenticationResult = await provider.authenticate(request); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.state).toBeUndefined(); - }); - - it('fails if token from `authorization` header is rejected.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-invalid-token' }, - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = mockAuthenticatedUser(); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-invalid-token' }, - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), - }), - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser - // In response to call with a token from header. - .mockRejectedValueOnce(failureReason) - // In response to a call with a token from session (not expected to be called). - .mockResolvedValueOnce(user); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); - - const authenticationResult = await provider.authenticate(request, state); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 6d5aa9f01f2ea..0fec62317c802 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -9,8 +9,9 @@ import { DetailedPeerCertificate } from 'tls'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider } from './base'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { Tokens } from '../tokens'; +import { BaseAuthenticationProvider } from './base'; /** * The state supported by the provider. @@ -27,19 +28,6 @@ interface ProviderState { peerCertificateFingerprint256: string; } -/** - * Parses request's `Authorization` HTTP header if present and extracts authentication scheme. - * @param request Request instance to extract authentication scheme for. - */ -function getRequestAuthenticationScheme(request: KibanaRequest) { - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - return ''; - } - - return authorization.split(/\s+/)[0].toLowerCase(); -} - /** * Provider that supports PKI request authentication. */ @@ -57,19 +45,13 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - const authenticationScheme = getRequestAuthenticationScheme(request); - if (authenticationScheme && authenticationScheme !== 'bearer') { - this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); + if (getHTTPAuthenticationScheme(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); return AuthenticationResult.notHandled(); } let authenticationResult = AuthenticationResult.notHandled(); - if (authenticationScheme) { - // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. - authenticationResult = await this.authenticateWithBearerScheme(request); - } - - if (state && authenticationResult.notHandled()) { + if (state) { authenticationResult = await this.authenticateViaState(request, state); // If access token expired or doesn't match to the certificate fingerprint we should try to get @@ -119,26 +101,6 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(`${this.options.basePath.serverBasePath}/logged_out`); } - /** - * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateWithBearerScheme(request: KibanaRequest) { - this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.'); - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.'); - return AuthenticationResult.succeeded(user); - } catch (err) { - this.logger.debug( - `Failed to authenticate request using "Bearer" authentication scheme: ${err.message}` - ); - return AuthenticationResult.failed(err); - } - } - /** * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index c4fdf0b25061b..23c2dc51abed3 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,18 +5,14 @@ */ import Boom from 'boom'; -import sinon from 'sinon'; import { ByteSizeValue } from '@kbn/config-schema'; -import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { - MockAuthenticationProviderOptions, - mockAuthenticationProviderOptions, - mockScopedClusterClient, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; @@ -63,7 +59,7 @@ describe('SAMLAuthenticationProvider', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', @@ -75,8 +71,7 @@ describe('SAMLAuthenticationProvider', () => { { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); @@ -99,7 +94,7 @@ describe('SAMLAuthenticationProvider', () => { {} ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -110,7 +105,7 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'user-initiated-login-token', refresh_token: 'user-initiated-login-refresh-token', }); @@ -121,8 +116,7 @@ describe('SAMLAuthenticationProvider', () => { { requestId: 'some-request-id', redirectURL: '' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); @@ -138,7 +132,7 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to the default location if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'idp-initiated-login-token', refresh_token: 'idp-initiated-login-refresh-token', }); @@ -148,8 +142,7 @@ describe('SAMLAuthenticationProvider', () => { samlResponse: 'saml-response-xml', }); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } ); @@ -166,9 +159,7 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('SAML response is stale!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlAuthenticate') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.login( request, @@ -176,8 +167,7 @@ describe('SAMLAuthenticationProvider', () => { { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); @@ -188,17 +178,16 @@ describe('SAMLAuthenticationProvider', () => { describe('IdP initiated login with existing session', () => { it('fails if new SAML Response is rejected.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const authorization = 'Bearer some-valid-token'; const user = mockAuthenticatedUser(); - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const failureReason = new Error('SAML response is invalid!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlAuthenticate') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.login( request, @@ -210,10 +199,20 @@ describe('SAMLAuthenticationProvider', () => { } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } + { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + } ); expect(authenticationResult.failed()).toBe(true); @@ -221,26 +220,27 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if fails to invalidate existing access/refresh tokens.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; const user = mockAuthenticatedUser(); - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', }); const failureReason = new Error('Failed to invalidate token!'); - mockOptions.tokens.invalidate.rejects(failureReason); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); const authenticationResult = await provider.login( request, @@ -248,14 +248,24 @@ describe('SAMLAuthenticationProvider', () => { state ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } + { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + } ); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, { + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: state.accessToken, refreshToken: state.refreshToken, }); @@ -265,25 +275,26 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to the home page if new SAML Response is for the same user.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; const user = { username: 'user' }; - mockScopedClusterClient(mockOptions.client) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', }); - mockOptions.tokens.invalidate.resolves(); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); const authenticationResult = await provider.login( request, @@ -291,14 +302,24 @@ describe('SAMLAuthenticationProvider', () => { state ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } + { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + } ); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, { + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: state.accessToken, refreshToken: state.refreshToken, }); @@ -308,28 +329,26 @@ describe('SAMLAuthenticationProvider', () => { }); it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; const existingUser = { username: 'user' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(existingUser); - - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(existingUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'new-user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', }); - mockOptions.tokens.invalidate.resolves(); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); const authenticationResult = await provider.login( request, @@ -337,14 +356,24 @@ describe('SAMLAuthenticationProvider', () => { state ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.authenticate' + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } + { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + } ); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, { + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: state.accessToken, refreshToken: state.refreshToken, }); @@ -363,7 +392,7 @@ describe('SAMLAuthenticationProvider', () => { redirectURLFragment: '#some-fragment', }); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -383,7 +412,7 @@ describe('SAMLAuthenticationProvider', () => { { redirectURL: '/test-base-path/some-path' } ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.notHandled()).toBe(true); }); @@ -391,7 +420,7 @@ describe('SAMLAuthenticationProvider', () => { it('redirects non-AJAX requests to the IdP remembering combined redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); @@ -405,11 +434,9 @@ describe('SAMLAuthenticationProvider', () => { { redirectURL: '/test-base-path/some-path' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlPrepare', - { body: { realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); expect(mockOptions.logger.warn).not.toHaveBeenCalled(); @@ -426,7 +453,7 @@ describe('SAMLAuthenticationProvider', () => { it('prepends redirect URL fragment with `#` if it does not have one.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); @@ -440,11 +467,9 @@ describe('SAMLAuthenticationProvider', () => { { redirectURL: '/test-base-path/some-path' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlPrepare', - { body: { realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); expect(mockOptions.logger.warn).toHaveBeenCalledWith( @@ -464,7 +489,7 @@ describe('SAMLAuthenticationProvider', () => { it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); @@ -478,11 +503,9 @@ describe('SAMLAuthenticationProvider', () => { { redirectURL: '/test-base-path/some-path' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlPrepare', - { body: { realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); expect(mockOptions.logger.warn).toHaveBeenCalledWith( @@ -503,7 +526,7 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.login( request, @@ -514,11 +537,9 @@ describe('SAMLAuthenticationProvider', () => { { redirectURL: '/test-base-path/some-path' } ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlPrepare', - { body: { realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -535,9 +556,21 @@ describe('SAMLAuthenticationProvider', () => { expect(authenticationResult.notHandled()).toBe(true); }); - it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => { + it('does not handle authentication via `authorization` header.', async () => { const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Basic some:credentials' }, + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, }); const authenticationResult = await provider.authenticate(request, { @@ -546,22 +579,22 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }); - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Basic some:credentials'); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); expect(authenticationResult.notHandled()).toBe(true); }); it('redirects non-AJAX request that can not be authenticated to the "capture fragment" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); const authenticationResult = await provider.authenticate(request); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe( @@ -575,14 +608,14 @@ describe('SAMLAuthenticationProvider', () => { path: `/s/foo/${'some-path'.repeat(10)}`, }); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); const authenticationResult = await provider.authenticate(request); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -604,11 +637,11 @@ describe('SAMLAuthenticationProvider', () => { }); const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.authenticate(request, null); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -618,7 +651,7 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'some-valid-token', @@ -626,12 +659,19 @@ describe('SAMLAuthenticationProvider', () => { }; const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, state); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ authorization }); @@ -640,23 +680,28 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, state); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -671,26 +716,34 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'valid-refresh-token', }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + return mockScopedClusterClient; + } + + if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + return mockScopedClusterClient; + } + + throw new Error('Unexpected call'); + }); - mockOptions.tokens.refresh - .withArgs(state.refreshToken) - .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); const authenticationResult = await provider.authenticate(request, state); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.authHeaders).toEqual({ @@ -705,28 +758,38 @@ describe('SAMLAuthenticationProvider', () => { }); it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, message: 'Something is wrong with refresh token.', }; - mockOptions.tokens.refresh.withArgs(state.refreshToken).rejects(refreshFailureReason); + mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); const authenticationResult = await provider.authenticate(request, state); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(refreshFailureReason); @@ -739,18 +802,28 @@ describe('SAMLAuthenticationProvider', () => { accessToken: 'expired-token', refreshToken: 'expired-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, state); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { 'kbn-xsrf': 'xsrf', authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toEqual( @@ -759,25 +832,35 @@ describe('SAMLAuthenticationProvider', () => { }); it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, state); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe( @@ -789,30 +872,41 @@ describe('SAMLAuthenticationProvider', () => { it('initiates SAML handshake for non-AJAX requests if refresh token is expired and request path is too large.', async () => { const request = httpServerMock.createKibanaRequest({ path: `/s/foo/${'some-path'.repeat(10)}`, + headers: {}, }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', }; + const authorization = `Bearer ${state.accessToken}`; - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, state); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -827,65 +921,6 @@ describe('SAMLAuthenticationProvider', () => { ); expect(authenticationResult.state).toEqual({ requestId: 'some-request-id', redirectURL: '' }); }); - - it('succeeds if `authorization` contains a valid token.', async () => { - const user = mockAuthenticatedUser(); - const authorization = 'Bearer some-valid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(request.headers.authorization).toBe('Bearer some-valid-token'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'saml' }); - expect(authenticationResult.state).toBeUndefined(); - }); - - it('fails if token from `authorization` header is rejected.', async () => { - const authorization = 'Bearer some-invalid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - const failureReason = { statusCode: 401 }; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); - - it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => { - const user = mockAuthenticatedUser(); - const authorization = 'Bearer some-invalid-token'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - const failureReason = { statusCode: 401 }; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(failureReason); - - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); }); describe('`logout` method', () => { @@ -901,7 +936,7 @@ describe('SAMLAuthenticationProvider', () => { deauthenticateResult = await provider.logout(request, { somethingElse: 'x' } as any); expect(deauthenticateResult.notHandled()).toBe(true); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if SAML logout call fails.', async () => { @@ -910,7 +945,7 @@ describe('SAMLAuthenticationProvider', () => { const refreshToken = 'x-saml-refresh-token'; const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.withArgs('shield.samlLogout').rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.logout(request, { username: 'user', @@ -918,8 +953,8 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -931,18 +966,14 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .rejects(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlInvalidate', - { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, + }); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -953,9 +984,7 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.samlLogout') - .resolves({ redirect: null }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -963,8 +992,8 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -977,9 +1006,7 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.samlLogout') - .resolves({ redirect: undefined }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -987,8 +1014,8 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -1003,9 +1030,7 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.samlLogout') - .resolves({ redirect: null }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -1013,8 +1038,8 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlLogout', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); @@ -1025,9 +1050,7 @@ describe('SAMLAuthenticationProvider', () => { it('relies on SAML invalidate call even if access token is presented.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .resolves({ redirect: null }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -1035,12 +1058,10 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'x-saml-refresh-token', }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlInvalidate', - { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, + }); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); @@ -1049,18 +1070,14 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .resolves({ redirect: null }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlInvalidate', - { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, + }); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); @@ -1069,18 +1086,14 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .resolves({ redirect: undefined }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); const authenticationResult = await provider.logout(request); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlInvalidate', - { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, + }); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); @@ -1091,9 +1104,9 @@ describe('SAMLAuthenticationProvider', () => { const accessToken = 'x-saml-token'; const refreshToken = 'x-saml-refresh-token'; - mockOptions.client.callAsInternalUser - .withArgs('shield.samlLogout') - .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', + }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -1101,7 +1114,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken, }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); @@ -1109,9 +1122,9 @@ describe('SAMLAuthenticationProvider', () => { it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', + }); const authenticationResult = await provider.logout(request, { username: 'user', @@ -1119,7 +1132,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'x-saml-refresh-token', }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index a817159fcd445..a7dbb14903479 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -9,9 +9,10 @@ import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; import { canRedirectRequest } from '../can_redirect_request'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { Tokens, TokenPair } from '../tokens'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; /** * The state supported by the provider (for the SAML handshake or established session). @@ -180,17 +181,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - // We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore. - let { - authenticationResult, - // eslint-disable-next-line prefer-const - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return authenticationResult; + if (getHTTPAuthenticationScheme(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); } - if (state && authenticationResult.notHandled()) { + let authenticationResult = AuthenticationResult.notHandled(); + if (state) { authenticationResult = await this.authenticateViaState(request, state); if ( authenticationResult.failed() && @@ -242,40 +239,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } } - /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: KibanaRequest) { - this.logger.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - this.logger.debug('Authorization header is not presented.'); - return { authenticationResult: AuthenticationResult.notHandled() }; - } - - const authenticationSchema = authorization.split(/\s+/)[0]; - if (authenticationSchema.toLowerCase() !== 'bearer') { - this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); - return { - authenticationResult: AuthenticationResult.notHandled(), - headerNotRecognized: true, - }; - } - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated via header.'); - return { authenticationResult: AuthenticationResult.succeeded(user) }; - } catch (err) { - this.logger.debug(`Failed to authenticate request via header: ${err.message}`); - return { authenticationResult: AuthenticationResult.failed(err) }; - } - } - /** * Validates whether request payload contains `SAMLResponse` parameter that can be exchanged * to a proper access token. If state is presented and includes request id then it means diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 0a55219e25d91..501bb1a6f5454 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -6,16 +6,12 @@ import Boom from 'boom'; import { errors } from 'elasticsearch'; -import sinon from 'sinon'; -import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { - MockAuthenticationProviderOptions, - mockAuthenticationProviderOptions, - mockScopedClusterClient, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; +import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; import { TokenAuthenticationProvider } from './token'; describe('TokenAuthenticationProvider', () => { @@ -28,25 +24,36 @@ describe('TokenAuthenticationProvider', () => { describe('`login` method', () => { it('succeeds with valid login attempt, creates session and authHeaders', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const user = mockAuthenticatedUser(); const credentials = { username: 'user', password: 'password' }; const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', ...credentials }, - }) - .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + }); const authenticationResult = await provider.login(request, credentials); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }); + expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); expect(authenticationResult.state).toEqual(tokenPair); @@ -58,15 +65,16 @@ describe('TokenAuthenticationProvider', () => { const credentials = { username: 'user', password: 'password' }; const authenticationError = new Error('Invalid credentials'); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', ...credentials }, - }) - .rejects(authenticationError); + mockOptions.client.callAsInternalUser.mockRejectedValue(authenticationError); const authenticationResult = await provider.login(request, credentials); - sinon.assert.notCalled(mockOptions.client.asScoped); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -76,26 +84,35 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if user cannot be retrieved during login attempt', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const credentials = { username: 'user', password: 'password' }; const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'password', ...credentials }, - }) - .resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + }); const authenticationError = new Error('Some error'); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.login(request, credentials); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); @@ -105,6 +122,33 @@ describe('TokenAuthenticationProvider', () => { }); describe('`authenticate` method', () => { + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + + it('does not handle authentication via `authorization` header even if state contains valid credentials.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + const authenticationResult = await provider.authenticate(request, { + accessToken: 'foo', + refreshToken: 'bar', + }); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + expect(authenticationResult.notHandled()).toBe(true); + }); + it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. @@ -128,35 +172,25 @@ describe('TokenAuthenticationProvider', () => { ); }); - it('succeeds if only `authorization` header is available and returns neither state nor authHeaders.', async () => { - const authorization = 'Bearer foo'; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - const user = mockAuthenticatedUser(); - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - }); - it('succeeds if only state is available.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const user = mockAuthenticatedUser(); const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); expect(authenticationResult.state).toBeUndefined(); @@ -169,27 +203,33 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); - - mockOptions.tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + return mockScopedClusterClient; + } + + if (scopeableRequest?.headers.authorization === 'Bearer newfoo') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + return mockScopedClusterClient; + } + + throw new Error('Unexpected call'); + }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer newfoo' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'newfoo', + refreshToken: 'newbar', + }); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); expect(authenticationResult.succeeded()).toBe(true); expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); @@ -198,76 +238,25 @@ describe('TokenAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); - it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Basic ***' }, - }); - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const user = mockAuthenticatedUser(); - const authorization = `Bearer ${tokenPair.accessToken}`; - - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Basic ***'); - expect(authenticationResult.notHandled()).toBe(true); - }); - - it('authenticates only via `authorization` header even if state is available.', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const authorization = `Bearer foo-from-header`; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - const user = mockAuthenticatedUser(); - - // GetUser will be called with request's `authorization` header. - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(request.headers.authorization).toEqual('Bearer foo-from-header'); - }); - - it('fails if authentication with token from header fails with unknown error', async () => { - const authorization = `Bearer foo`; - const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - - const authenticationError = new errors.InternalServerError('something went wrong'); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); - - const authenticationResult = await provider.authenticate(request); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); - }); - it('fails if authentication with token from state fails with unknown error.', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const authorization = `Bearer ${tokenPair.accessToken}`; const authenticationError = new errors.InternalServerError('something went wrong'); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.user).toBeUndefined(); @@ -276,22 +265,30 @@ describe('TokenAuthenticationProvider', () => { }); it('fails if token refresh is rejected with unknown error', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshError = new errors.InternalServerError('failed to refresh token'); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshError); + mockOptions.tokens.refresh.mockRejectedValue(refreshError); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -301,21 +298,29 @@ describe('TokenAuthenticationProvider', () => { }); it('redirects non-AJAX requests to /login and clears session if token cannot be refreshed', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); + const request = httpServerMock.createKibanaRequest({ path: '/some-path', headers: {} }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.redirected()).toBe(true); @@ -333,19 +338,27 @@ describe('TokenAuthenticationProvider', () => { path: '/some-path', }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + mockOptions.tokens.refresh.mockResolvedValue(null); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { 'kbn-xsrf': 'xsrf', authorization }, + }); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -360,28 +373,34 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); - - mockOptions.tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'newfoo', refreshToken: 'newbar' }); - const authenticationError = new errors.AuthenticationException('Some error'); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer newfoo' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + return mockScopedClusterClient; + } + + if (scopeableRequest?.headers.authorization === 'Bearer newfoo') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + return mockScopedClusterClient; + } + + throw new Error('Unexpected call'); + }); + + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'newfoo', + refreshToken: 'newbar', + }); const authenticationResult = await provider.authenticate(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); expect(authenticationResult.failed()).toBe(true); @@ -401,7 +420,7 @@ describe('TokenAuthenticationProvider', () => { deauthenticateResult = await provider.logout(request, null); expect(deauthenticateResult.redirected()).toBe(true); - sinon.assert.notCalled(mockOptions.tokens.invalidate); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); it('fails if `tokens.invalidate` fails', async () => { @@ -409,12 +428,12 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); expect(authenticationResult.failed()).toBe(true); expect(authenticationResult.error).toBe(failureReason); @@ -424,12 +443,12 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); @@ -439,12 +458,12 @@ describe('TokenAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ query: { yep: 'nope' } }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); const authenticationResult = await provider.logout(request, tokenPair); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); expect(authenticationResult.redirected()).toBe(true); expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope'); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 03fd003e2cbde..35d0aa9cc1816 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -8,9 +8,10 @@ import Boom from 'boom'; import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { BaseAuthenticationProvider } from './base'; import { canRedirectRequest } from '../can_redirect_request'; +import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { Tokens, TokenPair } from '../tokens'; +import { BaseAuthenticationProvider } from './base'; /** * Describes the parameters that are required by the provider to process the initial login request. @@ -34,12 +35,6 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'token'; - /** - * Performs initial login request using username and password. - * @param request Request instance. - * @param loginAttempt Login attempt description. - * @param [state] Optional state object associated with the provider. - */ /** * Performs initial login request using username and password. * @param request Request instance. @@ -87,18 +82,13 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async authenticate(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`); - // if there isn't a payload, try header-based token auth - const { - authenticationResult: headerAuthResult, - headerNotRecognized, - } = await this.authenticateViaHeader(request); - if (headerNotRecognized) { - return headerAuthResult; + if (getHTTPAuthenticationScheme(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); } - let authenticationResult = headerAuthResult; - // if we still can't attempt auth, try authenticating via state (session token) - if (authenticationResult.notHandled() && state) { + let authenticationResult = AuthenticationResult.notHandled(); + if (state) { authenticationResult = await this.authenticateViaState(request, state); if ( authenticationResult.failed() && @@ -111,6 +101,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // finally, if authentication still can not be handled for this // request/state combination, redirect to the login page if appropriate if (authenticationResult.notHandled() && canRedirectRequest(request)) { + this.logger.debug('Redirecting request to Login page.'); authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); } @@ -143,40 +134,6 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { ); } - /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. - */ - private async authenticateViaHeader(request: KibanaRequest) { - this.logger.debug('Trying to authenticate via header.'); - - const authorization = request.headers.authorization; - if (!authorization || typeof authorization !== 'string') { - this.logger.debug('Authorization header is not presented.'); - return { authenticationResult: AuthenticationResult.notHandled() }; - } - - const authenticationSchema = authorization.split(/\s+/)[0]; - if (authenticationSchema.toLowerCase() !== 'bearer') { - this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`); - return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true }; - } - - try { - const user = await this.getUser(request); - - this.logger.debug('Request has been authenticated via header.'); - - // We intentionally do not store anything in session state because token - // header auth can only be used on a request by request basis. - return { authenticationResult: AuthenticationResult.succeeded(user) }; - } catch (err) { - this.logger.debug(`Failed to authenticate request via header: ${err.message}`); - return { authenticationResult: AuthenticationResult.failed(err) }; - } - } - /** * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index f7374eedb5520..19110cc50da9b 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -13,57 +13,78 @@ import { createConfig$, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "authc": Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "authc": Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` - Object { - "authc": Object { - "providers": Array [ - "basic", - ], - }, - "cookieName": "sid", - "loginAssistanceMessage": "", - "secureCookies": false, - "session": Object { - "idleTimeout": null, - "lifespan": null, - }, - } - `); + Object { + "authc": Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Array [ + "basic", + ], + }, + "cookieName": "sid", + "loginAssistanceMessage": "", + "secureCookies": false, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, + } + `); }); it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => { @@ -80,6 +101,20 @@ describe('config schema', () => { ); }); + it('should throw error if `http` provider is explicitly set in xpack.security.authc.providers', () => { + expect(() => + ConfigSchema.validate({ authc: { providers: ['http'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc]: \`http\` authentication provider cannot be specified in \`xpack.security.authc.providers\`. Use \`xpack.security.authc.http.enabled\` instead."` + ); + + expect(() => + ConfigSchema.validate({ authc: { providers: ['basic', 'http'] } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc]: \`http\` authentication provider cannot be specified in \`xpack.security.authc.providers\`. Use \`xpack.security.authc.http.enabled\` instead."` + ); + }); + describe('authc.oidc', () => { it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { expect(() => @@ -101,15 +136,22 @@ describe('config schema', () => { authc: { providers: ['oidc'], oidc: { realm: 'realm-1' } }, }).authc ).toMatchInlineSnapshot(` - Object { - "oidc": Object { - "realm": "realm-1", - }, - "providers": Array [ - "oidc", - ], - } - `); + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + ], + } + `); }); it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => { @@ -126,16 +168,23 @@ describe('config schema', () => { authc: { providers: ['oidc', 'basic'], oidc: { realm: 'realm-1' } }, }).authc ).toMatchInlineSnapshot(` - Object { - "oidc": Object { - "realm": "realm-1", - }, - "providers": Array [ - "oidc", - "basic", - ], - } - `); + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "oidc": Object { + "realm": "realm-1", + }, + "providers": Array [ + "oidc", + "basic", + ], + } + `); }); it(`realm is not allowed when authc.providers is "['basic']"`, async () => { @@ -164,18 +213,25 @@ describe('config schema', () => { authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, }).authc ).toMatchInlineSnapshot(` - Object { - "providers": Array [ - "saml", - ], - "saml": Object { - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, - "realm": "realm-1", - }, - } - `); + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Array [ + "saml", + ], + "saml": Object { + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "realm": "realm-1", + }, + } + `); }); it('`realm` is not allowed if saml provider is not enabled', async () => { @@ -312,4 +368,40 @@ describe('createConfig$()', () => { expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); }); + + it('should include `http` authentication provider by default', async () => { + let config = (await mockAndCreateConfig(true, {})).config; + expect(config.authc.providers).toEqual(['basic', 'http']); + + config = ( + await mockAndCreateConfig(true, { authc: { providers: ['saml'], saml: { realm: 'saml1' } } }) + ).config; + expect(config.authc.providers).toEqual(['saml', 'http']); + + config = ( + await mockAndCreateConfig(true, { + authc: { providers: ['saml', 'basic'], saml: { realm: 'saml1' } }, + }) + ).config; + expect(config.authc.providers).toEqual(['saml', 'basic', 'http']); + }); + + it('should not include `http` authentication provider if it is disabled', async () => { + let config = (await mockAndCreateConfig(true, { authc: { http: { enabled: false } } })).config; + expect(config.authc.providers).toEqual(['basic']); + + config = ( + await mockAndCreateConfig(true, { + authc: { providers: ['saml'], saml: { realm: 'saml1' }, http: { enabled: false } }, + }) + ).config; + expect(config.authc.providers).toEqual(['saml']); + + config = ( + await mockAndCreateConfig(true, { + authc: { providers: ['saml', 'basic'], saml: { realm: 'saml1' }, http: { enabled: false } }, + }) + ).config; + expect(config.authc.providers).toEqual(['saml', 'basic']); + }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index db8c48f314d7c..f5e0914245097 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -39,17 +39,31 @@ export const ConfigSchema = schema.object( lifespan: schema.nullable(schema.duration()), }), secureCookies: schema.boolean({ defaultValue: false }), - authc: schema.object({ - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), - oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), - saml: providerOptionsSchema( - 'saml', - schema.object({ - realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), - }) - ), - }), + authc: schema.object( + { + providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), + saml: providerOptionsSchema( + 'saml', + schema.object({ + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) + ), + http: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + autoSchemesEnabled: schema.boolean({ defaultValue: true }), + schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), + }), + }, + { + validate(value) { + if (value.providers.includes('http')) { + return '`http` authentication provider cannot be specified in `xpack.security.authc.providers`. Use `xpack.security.authc.http.enabled` instead.'; + } + }, + } + ), }, // This option should be removed as soon as we entirely migrate config from legacy Security plugin. { allowUnknowns: true } @@ -86,6 +100,11 @@ export function createConfig$(context: PluginInitializerContext, isTLSEnabled: b secureCookies = true; } + // For the BWC reasons we always include HTTP authentication provider unless it's explicitly disabled. + if (config.authc.http.enabled) { + config.authc.providers.push('http'); + } + return { ...config, encryptionKey, diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index c0e86b289fe54..e1167af0be7f0 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -32,6 +32,17 @@ export const config: PluginConfigDescriptor> = { deprecations: ({ rename, unused }) => [ rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), + (settings, fromPath, log) => { + const hasProvider = (provider: string) => + settings?.xpack?.security?.authc?.providers?.includes(provider) ?? false; + + if (hasProvider('basic') && hasProvider('token')) { + log( + 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' + ); + } + return settings; + }, ], }; export const plugin: PluginInitializer< diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 56aad4ece3e95..6f5c79e873e86 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -28,6 +28,7 @@ describe('Security Plugin', () => { authc: { providers: ['saml', 'token'], saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, + http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, }, }) ); @@ -77,6 +78,7 @@ describe('Security Plugin', () => { "getSessionInfo": [Function], "invalidateAPIKey": [Function], "isAuthenticated": [Function], + "isProviderEnabled": [Function], "login": [Function], "logout": [Function], }, diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index be17b3e29f854..cc1c94d799be6 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -33,7 +33,9 @@ describe('Basic authentication routes', () => { let mockContext: RequestHandlerContext; beforeEach(() => { router = httpServiceMock.createRouter(); + authc = authenticationMock.create(); + authc.isProviderEnabled.mockImplementation(provider => provider === 'basic'); mockContext = ({ licensing: { @@ -166,6 +168,22 @@ describe('Basic authentication routes', () => { value: { username: 'user', password: 'password' }, }); }); + + it('prefers `token` authentication provider if it is enabled', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + authc.isProviderEnabled.mockImplementation( + provider => provider === 'token' || provider === 'basic' + ); + + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(204); + expect(response.payload).toBeUndefined(); + expect(authc.login).toHaveBeenCalledWith(mockRequest, { + provider: 'token', + value: { username: 'user', password: 'password' }, + }); + }); }); }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts index 453dc1c4ea3b5..db36e45fc07e8 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.ts @@ -25,16 +25,13 @@ export function defineBasicRoutes({ router, authc, config }: RouteDefinitionPara options: { authRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { - const { username, password } = request.body; + // We should prefer `token` over `basic` if possible. + const loginAttempt = authc.isProviderEnabled('token') + ? { provider: 'token', value: request.body } + : { provider: 'basic', value: request.body }; try { - // We should prefer `token` over `basic` if possible. - const providerToLoginWith = config.authc.providers.includes('token') ? 'token' : 'basic'; - const authenticationResult = await authc.login(request, { - provider: providerToLoginWith, - value: { username, password }, - }); - + const authenticationResult = await authc.login(request, loginAttempt); if (!authenticationResult.succeeded()) { return response.unauthorized({ body: authenticationResult.error }); } diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index 6035025564cbf..a774edfb4ab2c 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -27,18 +27,15 @@ export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); defineCommonRoutes(params); - if ( - params.config.authc.providers.includes('basic') || - params.config.authc.providers.includes('token') - ) { + if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { defineBasicRoutes(params); } - if (params.config.authc.providers.includes('saml')) { + if (params.authc.isProviderEnabled('saml')) { defineSAMLRoutes(params); } - if (params.config.authc.providers.includes('oidc')) { + if (params.authc.isProviderEnabled('oidc')) { defineOIDCRoutes(params); } } diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/api_integration/apis/security/session.ts index d819dd38dddb1..ef7e48388ff66 100644 --- a/x-pack/test/api_integration/apis/security/session.ts +++ b/x-pack/test/api_integration/apis/security/session.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const config = getService('config'); const kibanaServerConfig = config.get('servers.kibana'); @@ -25,7 +25,7 @@ export default function({ getService }: FtrProviderContext) { return response; }; const getSessionInfo = async () => - supertest + supertestWithoutAuth .get('/internal/security/session') .set('kbn-xsrf', 'xxx') .set('kbn-system-request', 'true') @@ -33,7 +33,7 @@ export default function({ getService }: FtrProviderContext) { .send() .expect(200); const extendSession = async () => - supertest + supertestWithoutAuth .post('/internal/security/session') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) @@ -42,7 +42,7 @@ export default function({ getService }: FtrProviderContext) { .then(saveCookie); beforeEach(async () => { - await supertest + await supertestWithoutAuth .post('/internal/security/login') .set('kbn-xsrf', 'xxx') .send({ username: validUsername, password: validPassword }) From caed9b3b0bb0ffc1c764217d7da6e3081e94b29c Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 27 Feb 2020 10:30:01 +0100 Subject: [PATCH 2/3] Review#1: improve tests. --- .../authentication/providers/basic.test.ts | 153 ++-- .../authentication/providers/http.test.ts | 85 ++- .../server/authentication/providers/http.ts | 6 +- .../authentication/providers/kerberos.test.ts | 285 ++++--- .../authentication/providers/oidc.test.ts | 357 +++++---- .../authentication/providers/pki.test.ts | 230 +++--- .../authentication/providers/saml.test.ts | 710 +++++++++--------- .../authentication/providers/token.test.ts | 242 +++--- 8 files changed, 993 insertions(+), 1075 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index dfa9639fa3583..c8aaadfe6d390 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -8,12 +8,27 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { mockAuthenticationProviderOptions } from './base.mock'; +import { IClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { BasicAuthenticationProvider } from './basic'; function generateAuthorizationHeader(username: string, password: string) { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; } +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('BasicAuthenticationProvider', () => { let provider: BasicAuthenticationProvider; let mockOptions: ReturnType; @@ -32,22 +47,16 @@ describe('BasicAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.login( - httpServerMock.createKibanaRequest({ headers: {} }), - credentials + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), credentials) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: { authorization }, + state: { authorization }, + }) ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toEqual({ authorization }); - expect(authenticationResult.authHeaders).toEqual({ authorization }); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); }); it('fails if user cannot be retrieved during login attempt', async () => { @@ -60,20 +69,13 @@ describe('BasicAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.login(request, credentials); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); }); @@ -81,54 +83,57 @@ describe('BasicAuthenticationProvider', () => { it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. - const authenticationResult = await provider.authenticate( - httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), - null - ); - - expect(authenticationResult.notHandled()).toBe(true); + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), + null + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); }); it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { - const authenticationResult = await provider.authenticate( - httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }), - null - ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path # that needs to be encoded', + }), + null + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + ) ); }); it('does not handle authentication if state exists, but authorization property is missing.', async () => { - const authenticationResult = await provider.authenticate( - httpServerMock.createKibanaRequest(), - {} - ); - expect(authenticationResult.notHandled()).toBe(true); + await expect( + provider.authenticate(httpServerMock.createKibanaRequest(), {}) + ).resolves.toEqual(AuthenticationResult.notHandled()); }); it('does not handle authentication via `authorization` header.', async () => { const authorization = generateAuthorizationHeader('user', 'password'); const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe(authorization); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header even if state contains valid credentials.', async () => { const authorization = generateAuthorizationHeader('user', 'password'); const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - const authenticationResult = await provider.authenticate(request, { authorization }); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe(authorization); - expect(authenticationResult.notHandled()).toBe(true); }); it('succeeds if only state is available.', async () => { @@ -140,19 +145,11 @@ describe('BasicAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, { authorization }); - - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toEqual({ authorization }); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); }); it('fails if state contains invalid credentials.', async () => { @@ -164,40 +161,30 @@ describe('BasicAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, { authorization }); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.error).toBe(authenticationError); }); }); describe('`logout` method', () => { it('always redirects to the login page.', async () => { - const request = httpServerMock.createKibanaRequest(); - const deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.redirected()).toBe(true); - expect(deauthenticateResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); + await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + ); }); it('passes query string parameters to the login page.', async () => { - const request = httpServerMock.createKibanaRequest({ - query: { next: '/app/ml', msg: 'SESSION_EXPIRED' }, - }); - const deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.redirected()).toBe(true); - expect(deauthenticateResult.redirectURL).toBe( - '/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' + await expect( + provider.logout( + httpServerMock.createKibanaRequest({ query: { next: '/app/ml', msg: 'SESSION_EXPIRED' } }) + ) + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED') ); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index bd70ad9537976..94237f9a3d538 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -8,9 +8,27 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { HTTPAuthenticationProvider } from './http'; +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('HTTPAuthenticationProvider', () => { let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { @@ -43,12 +61,11 @@ describe('HTTPAuthenticationProvider', () => { autoSchemesEnabled: true, schemes: ['apikey'], }); - const authenticationResult = await provider.login(); + + await expect(provider.login()).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.notHandled()).toBe(true); }); }); @@ -138,33 +155,35 @@ describe('HTTPAuthenticationProvider', () => { ]; it('does not handle authentication for requests without `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest(); - const provider = new HTTPAuthenticationProvider(mockOptions, { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], }); - const authenticationResult = await provider.authenticate(request); + + await expect(provider.authenticate(httpServerMock.createKibanaRequest())).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { authorization: '' } }); - const provider = new HTTPAuthenticationProvider(mockOptions, { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], }); - const authenticationResult = await provider.authenticate(request); + + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { @@ -182,10 +201,12 @@ describe('HTTPAuthenticationProvider', () => { autoSchemesEnabled, schemes, }); - const authenticationResult = await provider.authenticate(request); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(request.headers.authorization).toBe(header); - expect(authenticationResult.notHandled()).toBe(true); } expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -208,21 +229,13 @@ describe('HTTPAuthenticationProvider', () => { autoSchemesEnabled, schemes, }); - const authenticationResult = await provider.authenticate(request); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization: header }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded({ ...user, authentication_provider: 'http' }) ); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'http' }); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); + expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); + expect(request.headers.authorization).toBe(header); } }); @@ -243,20 +256,13 @@ describe('HTTPAuthenticationProvider', () => { autoSchemesEnabled, schemes, }); - const authenticationResult = await provider.authenticate(request); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization: header }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); + expect(request.headers.authorization).toBe(header); } }); @@ -269,12 +275,11 @@ describe('HTTPAuthenticationProvider', () => { autoSchemesEnabled: true, schemes: ['apikey'], }); - const authenticationResult = await provider.logout(); + + await expect(provider.logout()).resolves.toEqual(DeauthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.notHandled()).toBe(true); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index 6905c35a9977d..c06958ad7f350 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -39,14 +39,14 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { constructor( protected readonly options: Readonly, - proxyOptions?: Readonly> + httpOptions?: Readonly> ) { super(options); this.supportedSchemes = new Set( - (proxyOptions?.schemes ?? []).map(scheme => scheme.toLowerCase()) + (httpOptions?.schemes ?? []).map(scheme => scheme.toLowerCase()) ); - this.autoSchemesEnabled = proxyOptions?.autoSchemesEnabled ?? false; + this.autoSchemesEnabled = httpOptions?.autoSchemesEnabled ?? false; if (this.supportedSchemes.size === 0 && !this.autoSchemesEnabled) { throw new Error('Supported schemes should be specified'); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 020a5159d521e..2f0431c98a295 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -11,9 +11,27 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { KerberosAuthenticationProvider } from './kerberos'; +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; @@ -28,12 +46,13 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { @@ -45,12 +64,13 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { @@ -60,16 +80,13 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle requests if backend does not support Kerberos.', async () => { @@ -81,78 +98,71 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - - expect(authenticationResult.notHandled()).toBe(true); }); it('fails if state is present, but backend does not support Kerberos.', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) - ); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - ElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) - ) + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); }); it('fails if request authentication is failed with non-401 error.', async () => { - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const failureReason = new errors.ServiceUnavailable(); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('status', 503); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); }); it('gets a token pair in exchange to SPNEGO one and stores it in the state.', async () => { @@ -169,28 +179,25 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { + authHeaders: { authorization: 'Bearer some-token' }, + state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer some-token' }); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', - }); }); it('requests auth response header if token pair is complemented with Kerberos response token.', async () => { @@ -208,30 +215,26 @@ describe('KerberosAuthenticationProvider', () => { kerberos_authentication_response_token: 'response-token', }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { + authHeaders: { authorization: 'Bearer some-token' }, + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, + state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer some-token' }); - expect(authenticationResult.authResponseHeaders).toEqual({ - 'WWW-Authenticate': 'Negotiate response-token', - }); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', - }); }); it('fails with `Negotiate response-token` if cannot complete context with a response token.', async () => { @@ -246,18 +249,17 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized(), { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, + }) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual(Boom.unauthorized()); - expect(authenticationResult.authResponseHeaders).toEqual({ - 'WWW-Authenticate': 'Negotiate response-token', - }); }); it('fails with `Negotiate` if cannot create context using provided SPNEGO token.', async () => { @@ -272,18 +274,17 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized(), { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual(Boom.unauthorized()); - expect(authenticationResult.authResponseHeaders).toEqual({ - 'WWW-Authenticate': 'Negotiate', - }); }); it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => { @@ -294,16 +295,15 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('fails if could not retrieve user using the new access token.', async () => { @@ -320,23 +320,19 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); expect(request.headers.authorization).toBe('negotiate spnego'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('succeeds if state contains a valid token.', async () => { @@ -352,18 +348,16 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { authHeaders: { authorization } } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ authorization }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.state).toBeUndefined(); }); it('succeeds with valid session even if requiring a token refresh', async () => { @@ -394,15 +388,19 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'newbar', }); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { + authHeaders: { authorization: 'Bearer newfoo' }, + state: { accessToken: 'newfoo', refreshToken: 'newbar' }, + } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -418,46 +416,43 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Bearer ${tokenPair.accessToken}` }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expect(mockScopedClusterClient.callAsInternalUser).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - ElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) - ) + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); }); }); @@ -465,11 +460,11 @@ describe('KerberosAuthenticationProvider', () => { it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - let deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - deauthenticateResult = await provider.logout(request, null); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); @@ -481,13 +476,12 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { @@ -499,13 +493,12 @@ describe('KerberosAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.logout(request, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 5a0480b59bab1..c2fc4b8a10aff 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -10,9 +10,28 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { ElasticsearchErrorHelpers, KibanaRequest } from '../../../../../../src/core/server'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + KibanaRequest, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; @@ -51,30 +70,27 @@ describe('OIDCAuthenticationProvider', () => { '&login_hint=loginhint', }); - const authenticationResult = await provider.login(request, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, - iss: 'theissuer', - loginHint: 'loginhint', - }); + await expect( + provider.login(request, { + flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + iss: 'theissuer', + loginHint: 'loginhint', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { state: { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/' } } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { iss: 'theissuer', login_hint: 'loginhint' }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + - '&login_hint=loginhint' - ); - expect(authenticationResult.state).toEqual({ - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/', - }); }); function defineAuthenticationFlowTests( @@ -92,11 +108,17 @@ describe('OIDCAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - const authenticationResult = await provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/some-path', - }); + await expect( + provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/some-path', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/some-path', { + state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + }) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.oidcAuthenticate', @@ -109,58 +131,52 @@ describe('OIDCAuthenticationProvider', () => { }, } ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/some-path'); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', - }); }); it('fails if authentication response is presented but session state does not contain the state parameter.', async () => { const { request, attempt } = getMocks(); - const authenticationResult = await provider.login(request, attempt, { - nextURL: '/base-path/some-path', - }); - - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest( - 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + await expect( + provider.login(request, attempt, { nextURL: '/base-path/some-path' }) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.badRequest( + 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + ) ) ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if authentication response is presented but session state does not contain redirect URL.', async () => { const { request, attempt } = getMocks(); - const authenticationResult = await provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - }); - - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest( - 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + await expect( + provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue' }) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.badRequest( + 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + ) ) ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if session state is not presented.', async () => { const { request, attempt } = getMocks(); - const authenticationResult = await provider.login(request, attempt, {}); + await expect(provider.login(request, attempt, {})).resolves.toEqual( + AuthenticationResult.failed( + Boom.badRequest( + 'Response session state does not have corresponding state or nonce parameters or redirect URL.' + ) + ) + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.failed()).toBe(true); }); it('fails if authentication response is not valid.', async () => { @@ -171,11 +187,13 @@ describe('OIDCAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/some-path', - }); + await expect( + provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/some-path', + }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.oidcAuthenticate', @@ -188,9 +206,6 @@ describe('OIDCAuthenticationProvider', () => { }, } ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); } @@ -226,10 +241,9 @@ describe('OIDCAuthenticationProvider', () => { describe('`authenticate` method', () => { it('does not handle AJAX request that can not be authenticated.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.notHandled()).toBe(true); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); }); it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { @@ -246,25 +260,26 @@ describe('OIDCAuthenticationProvider', () => { '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', }); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/s/foo/some-path', + }, + } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' - ); - expect(authenticationResult.state).toEqual({ - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/s/foo/some-path', - }); }); it('fails if OpenID Connect authentication request preparation fails.', async () => { @@ -273,14 +288,13 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('succeeds if state contains a valid token.', async () => { @@ -296,20 +310,16 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'oidc' }, + { authHeaders: { authorization } } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ authorization }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'oidc' }); - expect(authenticationResult.state).toBeUndefined(); }); it('does not handle authentication via `authorization` header.', async () => { @@ -317,11 +327,12 @@ describe('OIDCAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { @@ -329,14 +340,15 @@ describe('OIDCAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); + await expect( + provider.authenticate(request, { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('fails if token from the state is rejected because of unknown reason.', async () => { @@ -352,18 +364,13 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { @@ -394,21 +401,20 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'new-refresh-token', }); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'oidc' }, + { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }, + } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ - authorization: 'Bearer new-access-token', - }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'oidc' }); - expect(authenticationResult.state).toEqual({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - }); }); it('fails if token from the state is expired and refresh attempt failed too.', async () => { @@ -428,21 +434,16 @@ describe('OIDCAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(refreshFailureReason as any) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(refreshFailureReason); }); it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { @@ -469,7 +470,22 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/base-path/s/foo/some-path', + }, + } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -484,20 +500,6 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { body: { realm: `oidc1` }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' - ); - expect(authenticationResult.state).toEqual({ - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/s/foo/some-path', - }); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -513,23 +515,18 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { 'kbn-xsrf': 'xsrf', authorization }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') - ); }); }); @@ -537,14 +534,17 @@ describe('OIDCAuthenticationProvider', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = httpServerMock.createKibanaRequest(); - let deauthenticateResult = await provider.logout(request, {}); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request, undefined as any)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); - deauthenticateResult = await provider.logout(request, {}); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request, {})).resolves.toEqual( + DeauthenticationResult.notHandled() + ); - deauthenticateResult = await provider.logout(request, { nonce: 'x' }); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request, { nonce: 'x' })).resolves.toEqual( + DeauthenticationResult.notHandled() + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); @@ -557,18 +557,14 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request, { - accessToken, - refreshToken, - }); + await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => { @@ -578,18 +574,14 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - const authenticationResult = await provider.logout(request, { - accessToken, - refreshToken, - }); + await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => { @@ -601,17 +593,14 @@ describe('OIDCAuthenticationProvider', () => { redirect: 'http://fake-idp/logout&id_token_hint=thehint', }); - const authenticationResult = await provider.logout(request, { - accessToken, - refreshToken, - }); + await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 6cee9f970d9b2..3b5fa1bfa4d39 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -7,17 +7,23 @@ jest.mock('net'); jest.mock('tls'); +import { Socket } from 'net'; import { PeerCertificate, TLSSocket } from 'tls'; +import Boom from 'boom'; import { errors } from 'elasticsearch'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { PKIAuthenticationProvider } from './pki'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; -import { Socket } from 'net'; -import { getErrorStatusCode } from '../../errors'; interface MockPeerCertificate extends Partial { issuerCertificate: MockPeerCertificate; @@ -56,6 +62,18 @@ function getMockSocket({ return socket; } +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('PKIAuthenticationProvider', () => { let provider: PKIAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; @@ -72,12 +90,13 @@ describe('PKIAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { @@ -89,12 +108,13 @@ describe('PKIAuthenticationProvider', () => { peerCertificateFingerprint256: '2A:7A:C2:DD', }; - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle requests without certificate.', async () => { @@ -102,9 +122,10 @@ describe('PKIAuthenticationProvider', () => { socket: getMockSocket({ authorized: true }), }); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(authenticationResult.notHandled()).toBe(true); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); @@ -114,9 +135,10 @@ describe('PKIAuthenticationProvider', () => { socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), }); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(authenticationResult.notHandled()).toBe(true); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); @@ -128,12 +150,10 @@ describe('PKIAuthenticationProvider', () => { const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const authenticationResult = await provider.authenticate(request, state); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toMatchInlineSnapshot( - `[Error: Peer certificate is not available]` + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(new Error('Peer certificate is not available')) ); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); @@ -141,10 +161,9 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const authenticationResult = await provider.authenticate(request, state); - - expect(authenticationResult.failed()).toBe(true); - expect(getErrorStatusCode(authenticationResult.error)).toBe(401); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -158,10 +177,9 @@ describe('PKIAuthenticationProvider', () => { }); const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const authenticationResult = await provider.authenticate(request, state); - - expect(authenticationResult.failed()).toBe(true); - expect(getErrorStatusCode(authenticationResult.error)).toBe(401); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -184,7 +202,15 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { + authHeaders: { authorization: 'Bearer access-token' }, + state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { @@ -196,22 +222,11 @@ describe('PKIAuthenticationProvider', () => { }, }); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization: `Bearer access-token` }, + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); - expect(authenticationResult.state).toEqual({ - accessToken: 'access-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }); }); it('gets an access token in exchange to a self-signed certificate and stores it in the state.', async () => { @@ -229,29 +244,26 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { + authHeaders: { authorization: 'Bearer access-token' }, + state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization: `Bearer access-token` }, + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); - expect(authenticationResult.state).toEqual({ - accessToken: 'access-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }); }); it('invalidates existing token and gets a new one if fingerprints do not match.', async () => { @@ -269,7 +281,15 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { + authHeaders: { authorization: 'Bearer access-token' }, + state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + } + ) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ @@ -287,14 +307,6 @@ describe('PKIAuthenticationProvider', () => { }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); - expect(authenticationResult.state).toEqual({ - accessToken: 'access-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }); }); it('gets a new access token even if existing token is expired.', async () => { @@ -316,7 +328,15 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { + authHeaders: { authorization: 'Bearer access-token' }, + state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { @@ -329,14 +349,6 @@ describe('PKIAuthenticationProvider', () => { }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer access-token' }); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); - expect(authenticationResult.state).toEqual({ - accessToken: 'access-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }); }); it('fails with 401 if existing token is expired, but certificate is not present.', async () => { @@ -349,14 +361,13 @@ describe('PKIAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(getErrorStatusCode(authenticationResult.error)).toBe(401); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { @@ -370,7 +381,9 @@ describe('PKIAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { @@ -378,9 +391,6 @@ describe('PKIAuthenticationProvider', () => { }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('fails if could not retrieve user using the new access token.', async () => { @@ -398,30 +408,27 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, }); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization: `Bearer access-token` }, + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; const request = httpServerMock.createKibanaRequest({ + headers: {}, socket: getMockSocket({ authorized: true, peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), @@ -432,37 +439,40 @@ describe('PKIAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { authHeaders: { authorization: `Bearer ${state.accessToken}` } } + ) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer token' } }); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ - authorization: `Bearer ${state.accessToken}`, - }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'pki' }); - expect(authenticationResult.state).toBeUndefined(); }); it('fails if token from the state is rejected because of unknown reason.', async () => { const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; const request = httpServerMock.createKibanaRequest({ + headers: {}, socket: getMockSocket({ authorized: true, peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256), }), }); + const failureReason = new errors.ServiceUnavailable(); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('status', 503); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer token' } }); }); }); @@ -470,11 +480,11 @@ describe('PKIAuthenticationProvider', () => { it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - let deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - deauthenticateResult = await provider.logout(request, null); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); @@ -486,13 +496,12 @@ describe('PKIAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request, state); + await expect(provider.logout(request, state)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to `/logged_out` page if access token is invalidated successfully.', async () => { @@ -501,13 +510,12 @@ describe('PKIAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.logout(request, state); + await expect(provider.logout(request, state)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 23c2dc51abed3..cbdcfa0f0b025 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -11,8 +11,26 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; + +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; @@ -65,41 +83,44 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { + state: { + username: 'user', + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + }, + }) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path#some-app'); - expect(authenticationResult.state).toEqual({ - username: 'user', - accessToken: 'some-token', - refreshToken: 'some-refresh-token', - }); }); it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => { const request = httpServerMock.createKibanaRequest(); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - {} + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + {} + ) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.badRequest('SAML response state does not have corresponding request id.') + ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('SAML response state does not have corresponding request id.') - ); }); it('redirects to the default location if state contains empty redirect URL.', async () => { @@ -110,23 +131,25 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'user-initiated-login-refresh-token', }); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '' } + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/', { + state: { + accessToken: 'user-initiated-login-token', + refreshToken: 'user-initiated-login-refresh-token', + }, + }) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/'); - expect(authenticationResult.state).toEqual({ - accessToken: 'user-initiated-login-token', - refreshToken: 'user-initiated-login-refresh-token', - }); }); it('redirects to the default location if state is not presented.', async () => { @@ -137,22 +160,24 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'idp-initiated-login-refresh-token', }); - const authenticationResult = await provider.login(request, { - step: SAMLLoginStep.SAMLResponseReceived, - samlResponse: 'saml-response-xml', - }); + await expect( + provider.login(request, { + step: SAMLLoginStep.SAMLResponseReceived, + samlResponse: 'saml-response-xml', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/', { + state: { + accessToken: 'idp-initiated-login-token', + refreshToken: 'idp-initiated-login-refresh-token', + }, + }) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' } } ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/'); - expect(authenticationResult.state).toEqual({ - accessToken: 'idp-initiated-login-token', - refreshToken: 'idp-initiated-login-refresh-token', - }); }); it('fails if SAML Response is rejected.', async () => { @@ -161,19 +186,18 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('SAML response is stale!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } - ); + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); describe('IdP initiated login with existing session', () => { @@ -189,24 +213,19 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('SAML response is invalid!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { - username: 'user', - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - } - ); + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { + username: 'user', + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + } + ) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' - ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', @@ -214,9 +233,6 @@ describe('SAMLAuthenticationProvider', () => { body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, } ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('fails if fails to invalidate existing access/refresh tokens.', async () => { @@ -242,20 +258,15 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Failed to invalidate token!'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - state - ); + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + state + ) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' - ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', @@ -269,9 +280,6 @@ describe('SAMLAuthenticationProvider', () => { accessToken: state.accessToken, refreshToken: state.refreshToken, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to the home page if new SAML Response is for the same user.', async () => { @@ -296,20 +304,23 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - state + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + state + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/', { + state: { + username: 'user', + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + }, + }) ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' - ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', @@ -323,9 +334,6 @@ describe('SAMLAuthenticationProvider', () => { accessToken: state.accessToken, refreshToken: state.refreshToken, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/'); }); it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => { @@ -350,20 +358,23 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - state + await expect( + provider.login( + request, + { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + state + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/overwritten_session', { + state: { + username: 'new-user', + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + }, + }) ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.authenticate' - ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', @@ -377,9 +388,6 @@ describe('SAMLAuthenticationProvider', () => { accessToken: state.accessToken, refreshToken: state.refreshToken, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session'); }); }); @@ -387,34 +395,35 @@ describe('SAMLAuthenticationProvider', () => { it('fails if state is not available', async () => { const request = httpServerMock.createKibanaRequest(); - const authenticationResult = await provider.login(request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment', - }); + await expect( + provider.login(request, { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '#some-fragment', + }) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.badRequest('State does not include URL path to redirect to.') + ) + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('State does not include URL path to redirect to.') - ); }); it('does not handle AJAX requests.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment', - }, - { redirectURL: '/test-base-path/some-path' } - ); + await expect( + provider.login( + request, + { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '#some-fragment', + }, + { redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.notHandled()).toBe(true); }); it('redirects non-AJAX requests to the IdP remembering combined redirect URL.', async () => { @@ -425,13 +434,25 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment', - }, - { redirectURL: '/test-base-path/some-path' } + await expect( + provider.login( + request, + { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '#some-fragment', + }, + { redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-fragment', + }, + } + ) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { @@ -439,15 +460,6 @@ describe('SAMLAuthenticationProvider', () => { }); expect(mockOptions.logger.warn).not.toHaveBeenCalled(); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path#some-fragment', - }); }); it('prepends redirect URL fragment with `#` if it does not have one.', async () => { @@ -458,13 +470,25 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '../some-fragment', - }, - { redirectURL: '/test-base-path/some-path' } + await expect( + provider.login( + request, + { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '../some-fragment', + }, + { redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#../some-fragment', + }, + } + ) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { @@ -475,15 +499,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledWith( 'Redirect URL fragment does not start with `#`.' ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path#../some-fragment', - }); }); it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { @@ -494,13 +509,25 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment'.repeat(10), - }, - { redirectURL: '/test-base-path/some-path' } + await expect( + provider.login( + request, + { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '#some-fragment'.repeat(10), + }, + { redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path', + }, + } + ) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { @@ -511,15 +538,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledWith( 'Max URL size should not exceed 100b but it was 165b. Only URL path is captured.' ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path', - }); }); it('fails if SAML request preparation fails.', async () => { @@ -528,21 +546,20 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment', - }, - { redirectURL: '/test-base-path/some-path' } - ); + await expect( + provider.login( + request, + { + step: SAMLLoginStep.RedirectURLFragmentCaptured, + redirectURLFragment: '#some-fragment', + }, + { redirectURL: '/test-base-path/some-path' } + ) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); }); }); @@ -551,9 +568,9 @@ describe('SAMLAuthenticationProvider', () => { it('does not handle AJAX request that can not be authenticated.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.notHandled()).toBe(true); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); }); it('does not handle authentication via `authorization` header.', async () => { @@ -561,11 +578,12 @@ describe('SAMLAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { @@ -573,15 +591,16 @@ describe('SAMLAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request, { - username: 'user', - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }); + await expect( + provider.authenticate(request, { + username: 'user', + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('redirects non-AJAX request that can not be authenticated to the "capture fragment" page.', async () => { @@ -592,15 +611,14 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/api/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path' } } + ) + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/mock-server-basepath/api/security/saml/capture-url-fragment' - ); - expect(authenticationResult.state).toEqual({ redirectURL: '/base-path/s/foo/some-path' }); }); it('redirects non-AJAX request that can not be authenticated to the IdP if request path is too large.', async () => { @@ -613,7 +631,12 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '' } } + ) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, @@ -623,12 +646,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledWith( 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ requestId: 'some-request-id', redirectURL: '' }); }); it('fails if SAML request preparation fails.', async () => { @@ -639,14 +656,13 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.authenticate(request, null); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('succeeds if state contains a valid token.', async () => { @@ -663,20 +679,16 @@ describe('SAMLAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'saml' }, + { authHeaders: { authorization } } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ authorization }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'saml' }); - expect(authenticationResult.state).toBeUndefined(); }); it('fails if token from the state is rejected because of unknown reason.', async () => { @@ -693,18 +705,13 @@ describe('SAMLAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(failureReason as any) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { @@ -739,22 +746,24 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'new-refresh-token', }); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'saml' }, + { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { + username: 'user', + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }, + } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.authHeaders).toEqual({ - authorization: 'Bearer new-access-token', - }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'saml' }); - expect(authenticationResult.state).toEqual({ - username: 'user', - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - }); }); it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { @@ -778,21 +787,16 @@ describe('SAMLAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(refreshFailureReason as any) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(refreshFailureReason); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -812,23 +816,18 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { 'kbn-xsrf': 'xsrf', authorization }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') - ); }); it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { @@ -848,25 +847,19 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/api/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path' } } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/mock-server-basepath/api/security/saml/capture-url-fragment' - ); - expect(authenticationResult.state).toEqual({ redirectURL: '/base-path/s/foo/some-path' }); }); it('initiates SAML handshake for non-AJAX requests if refresh token is expired and request path is too large.', async () => { @@ -894,17 +887,17 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, state); + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '' } } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, @@ -914,12 +907,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).toHaveBeenCalledWith( 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - 'https://idp-host/path/login?SAMLRequest=some%20request%20' - ); - expect(authenticationResult.state).toEqual({ requestId: 'some-request-id', redirectURL: '' }); }); }); @@ -927,14 +914,13 @@ describe('SAMLAuthenticationProvider', () => { it('returns `notHandled` if state is not presented or does not include access token.', async () => { const request = httpServerMock.createKibanaRequest(); - let deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.notHandled()).toBe(true); - - deauthenticateResult = await provider.logout(request, {} as any); - expect(deauthenticateResult.notHandled()).toBe(true); - - deauthenticateResult = await provider.logout(request, { somethingElse: 'x' } as any); - expect(deauthenticateResult.notHandled()).toBe(true); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + await expect(provider.logout(request, {} as any)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); + await expect(provider.logout(request, { somethingElse: 'x' } as any)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); @@ -947,19 +933,14 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('fails if SAML invalidate call fails.', async () => { @@ -968,15 +949,14 @@ describe('SAMLAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request); + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { @@ -986,19 +966,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => { @@ -1008,19 +983,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => { @@ -1032,19 +1002,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', { body: { token: accessToken, refresh_token: refreshToken }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('relies on SAML invalidate call even if access token is presented.', async () => { @@ -1052,19 +1017,18 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - }); + await expect( + provider.logout(request, { + username: 'user', + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => { @@ -1072,15 +1036,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - const authenticationResult = await provider.logout(request); + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => { @@ -1088,15 +1051,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); - const authenticationResult = await provider.logout(request); + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); }); it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { @@ -1108,15 +1070,13 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual( + DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => { @@ -1126,15 +1086,17 @@ describe('SAMLAuthenticationProvider', () => { redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', }); - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - }); + await expect( + provider.logout(request, { + username: 'user', + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + }) + ).resolves.toEqual( + DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') + ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index 501bb1a6f5454..b1efe0a0475b4 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -11,9 +11,27 @@ import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server'; +import { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { TokenAuthenticationProvider } from './token'; +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + describe('TokenAuthenticationProvider', () => { let provider: TokenAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; @@ -40,24 +58,19 @@ describe('TokenAuthenticationProvider', () => { refresh_token: tokenPair.refreshToken, }); - const authenticationResult = await provider.login(request, credentials); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { authHeaders: { authorization }, state: tokenPair } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: 'password', ...credentials }, }); - - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.state).toEqual(tokenPair); - expect(authenticationResult.authHeaders).toEqual({ authorization }); }); it('fails if token cannot be generated during login attempt', async () => { @@ -67,7 +80,9 @@ describe('TokenAuthenticationProvider', () => { const authenticationError = new Error('Invalid credentials'); mockOptions.client.callAsInternalUser.mockRejectedValue(authenticationError); - const authenticationResult = await provider.login(request, credentials); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -77,10 +92,6 @@ describe('TokenAuthenticationProvider', () => { }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); it('fails if user cannot be retrieved during login attempt', async () => { @@ -99,14 +110,11 @@ describe('TokenAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.login(request, credentials); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { @@ -114,10 +122,6 @@ describe('TokenAuthenticationProvider', () => { }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); }); @@ -127,11 +131,12 @@ describe('TokenAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not handle authentication via `authorization` header even if state contains valid credentials.', async () => { @@ -139,36 +144,37 @@ describe('TokenAuthenticationProvider', () => { headers: { authorization: 'Bearer some-token' }, }); - const authenticationResult = await provider.authenticate(request, { - accessToken: 'foo', - refreshToken: 'bar', - }); + await expect( + provider.authenticate(request, { accessToken: 'foo', refreshToken: 'bar' }) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe('Bearer some-token'); - expect(authenticationResult.notHandled()).toBe(true); }); it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => { // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and // avoid triggering of redirect logic. - const authenticationResult = await provider.authenticate( - httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), - null - ); - - expect(authenticationResult.notHandled()).toBe(true); + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), + null + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); }); it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { - const authenticationResult = await provider.authenticate( - httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }), - null - ); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ + path: '/s/foo/some-path # that needs to be encoded', + }), + null + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded' + ) ); }); @@ -182,19 +188,15 @@ describe('TokenAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { authHeaders: { authorization } } + ) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toEqual({ authorization }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -226,15 +228,19 @@ describe('TokenAuthenticationProvider', () => { refreshToken: 'newbar', }); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { + authHeaders: { authorization: 'Bearer newfoo' }, + state: { accessToken: 'newfoo', refreshToken: 'newbar' }, + } + ) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -248,20 +254,13 @@ describe('TokenAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); it('fails if token refresh is rejected with unknown error', async () => { @@ -278,23 +277,16 @@ describe('TokenAuthenticationProvider', () => { const refreshError = new errors.InternalServerError('failed to refresh token'); mockOptions.tokens.refresh.mockRejectedValue(refreshError); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(refreshError) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(refreshError); }); it('redirects non-AJAX requests to /login and clears session if token cannot be refreshed', async () => { @@ -310,26 +302,18 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/login?next=%2Fbase-path%2Fsome-path', { + state: null, + }) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ - headers: { authorization }, - }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/base-path/login?next=%2Fbase-path%2Fsome-path' - ); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toEqual(null); - expect(authenticationResult.error).toBeUndefined(); }); it('does not redirect AJAX requests if token token cannot be refreshed', async () => { @@ -348,25 +332,18 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledTimes(1); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + expectAuthenticateCall(mockOptions.client, { headers: { 'kbn-xsrf': 'xsrf', authorization }, }); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') - ); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); }); it('fails if new access token is rejected after successful refresh', async () => { @@ -397,16 +374,14 @@ describe('TokenAuthenticationProvider', () => { refreshToken: 'newbar', }); - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); }); @@ -414,11 +389,13 @@ describe('TokenAuthenticationProvider', () => { it('returns `redirected` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - let deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.redirected()).toBe(true); + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + ); - deauthenticateResult = await provider.logout(request, null); - expect(deauthenticateResult.redirected()).toBe(true); + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); @@ -430,13 +407,12 @@ describe('TokenAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - const authenticationResult = await provider.logout(request, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('redirects to /login if tokens are invalidated successfully', async () => { @@ -445,13 +421,12 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.logout(request, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); }); it('redirects to /login with optional search parameters if tokens are invalidated successfully', async () => { @@ -460,13 +435,12 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - const authenticationResult = await provider.logout(request, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?yep=nope') + ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope'); }); }); }); From f7df6fdc15f0df7c7bae9ea047b56d7bdf9a75c6 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 27 Feb 2020 12:44:11 +0100 Subject: [PATCH 3/3] Review#1: rework the way we detect which additional schemes should be supported by the HTTP authentication provider. --- .../authentication/authenticator.test.ts | 267 +++++++++++------- .../server/authentication/authenticator.ts | 39 ++- .../authentication/providers/base.mock.ts | 1 - .../server/authentication/providers/base.ts | 8 +- .../authentication/providers/basic.test.ts | 4 + .../server/authentication/providers/basic.ts | 8 + .../authentication/providers/http.test.ts | 161 +++-------- .../server/authentication/providers/http.ts | 51 +--- .../authentication/providers/kerberos.test.ts | 4 + .../authentication/providers/kerberos.ts | 8 + .../authentication/providers/oidc.test.ts | 4 + .../server/authentication/providers/oidc.ts | 8 + .../authentication/providers/pki.test.ts | 4 + .../server/authentication/providers/pki.ts | 8 + .../authentication/providers/saml.test.ts | 4 + .../server/authentication/providers/saml.ts | 8 + .../authentication/providers/token.test.ts | 4 + .../server/authentication/providers/token.ts | 8 + x-pack/plugins/security/server/config.test.ts | 50 ---- x-pack/plugins/security/server/config.ts | 46 ++- 20 files changed, 346 insertions(+), 349 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 16803ef8503da..af019ff10dedc 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -6,6 +6,7 @@ jest.mock('./providers/basic'); jest.mock('./providers/saml'); +jest.mock('./providers/http'); import Boom from 'boom'; import { duration, Duration } from 'moment'; @@ -27,9 +28,11 @@ import { BasicAuthenticationProvider } from './providers'; function getMockOptions({ session, providers, + http = {}, }: { session?: AuthenticatorOptions['config']['session']; providers?: string[]; + http?: Partial; } = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), @@ -41,7 +44,7 @@ function getMockOptions({ providers: providers || [], oidc: {}, saml: {}, - http: { enabled: true, autoSchemesEnabled: true, schemes: [] }, + http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], ...http }, }, }, sessionStorageFactory: sessionStorageMock.createFactory(), @@ -55,8 +58,13 @@ describe('Authenticator', () => { login: jest.fn(), authenticate: jest.fn(), logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), }; + jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + })); + jest .requireMock('./providers/basic') .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); @@ -76,6 +84,70 @@ describe('Authenticator', () => { 'Unsupported authentication provider name: super-basic.' ); }); + + describe('HTTP authentication provider', () => { + beforeEach(() => { + jest + .requireMock('./providers/basic') + .BasicAuthenticationProvider.mockImplementation(() => ({ + getHTTPAuthenticationScheme: jest.fn().mockReturnValue('basic'), + })); + }); + + afterEach(() => jest.resetAllMocks()); + + it('enabled by default', () => { + const authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('http')).toBe(true); + + expect( + jest.requireMock('./providers/http').HTTPAuthenticationProvider + ).toHaveBeenCalledWith(expect.anything(), { + supportedSchemes: new Set(['apikey', 'basic']), + }); + }); + + it('includes all required schemes if `autoSchemesEnabled` is enabled', () => { + const authenticator = new Authenticator( + getMockOptions({ providers: ['basic', 'kerberos'] }) + ); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderEnabled('http')).toBe(true); + + expect( + jest.requireMock('./providers/http').HTTPAuthenticationProvider + ).toHaveBeenCalledWith(expect.anything(), { + supportedSchemes: new Set(['apikey', 'basic', 'bearer']), + }); + }); + + it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => { + const authenticator = new Authenticator( + getMockOptions({ providers: ['basic', 'kerberos'], http: { autoSchemesEnabled: false } }) + ); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderEnabled('http')).toBe(true); + + expect( + jest.requireMock('./providers/http').HTTPAuthenticationProvider + ).toHaveBeenCalledWith(expect.anything(), { supportedSchemes: new Set(['apikey']) }); + }); + + it('disabled if explicitly disabled', () => { + const authenticator = new Authenticator( + getMockOptions({ providers: ['basic'], http: { enabled: false } }) + ); + expect(authenticator.isProviderEnabled('basic')).toBe(true); + expect(authenticator.isProviderEnabled('http')).toBe(false); + + expect( + jest.requireMock('./providers/http').HTTPAuthenticationProvider + ).not.toHaveBeenCalled(); + }); + }); }); describe('`login` method', () => { @@ -126,12 +198,9 @@ describe('Authenticator', () => { AuthenticationResult.failed(failureReason) ); - const authenticationResult = await authenticator.login(request, { - provider: 'basic', - value: {}, - }); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); }); it('returns user that authentication provider returns.', async () => { @@ -142,13 +211,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); - const authenticationResult = await authenticator.login(request, { - provider: 'basic', - value: {}, - }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' }); + await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); }); it('creates session whenever authentication provider returns state', async () => { @@ -160,12 +225,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - const authenticationResult = await authenticator.login(request, { - provider: 'basic', - value: {}, - }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -176,11 +238,9 @@ describe('Authenticator', () => { it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); - const authenticationResult = await authenticator.login(request, { - provider: 'token', - value: {}, - }); - expect(authenticationResult.notHandled()).toBe(true); + await expect(authenticator.login(request, { provider: 'token', value: {} })).resolves.toEqual( + AuthenticationResult.notHandled() + ); }); it('clears session if it belongs to a different provider.', async () => { @@ -191,12 +251,9 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); - const authenticationResult = await authenticator.login(request, { - provider: 'basic', - value: credentials, - }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toBe(user); + await expect( + authenticator.login(request, { provider: 'basic', value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( request, @@ -216,12 +273,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: null }) ); - const authenticationResult = await authenticator.login(request, { - provider: 'basic', - value: {}, - }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: null }) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -277,10 +331,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' }); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) + ); }); it('creates session whenever authentication provider returns state for system API requests', async () => { @@ -294,9 +347,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - const systemAPIAuthenticationResult = await authenticator.authenticate(request); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -316,9 +369,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - const systemAPIAuthenticationResult = await authenticator.authenticate(request); - expect(systemAPIAuthenticationResult.succeeded()).toBe(true); - expect(systemAPIAuthenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { authorization } }) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -338,9 +391,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); @@ -357,9 +410,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith(mockSessVal); @@ -392,9 +445,9 @@ describe('Authenticator', () => { jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -437,9 +490,9 @@ describe('Authenticator', () => { jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -485,9 +538,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user) ); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -517,13 +570,15 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); + const failureReason = new Error('some error'); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.failed(new Error('some error')) + AuthenticationResult.failed(failureReason) ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.failed()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); @@ -534,13 +589,15 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); + const failureReason = new Error('some error'); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.failed(new Error('some error')) + AuthenticationResult.failed(failureReason) ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.failed()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); @@ -558,9 +615,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: newState }) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -582,9 +639,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: newState }) + ); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -604,8 +661,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.failed()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -621,8 +679,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.failed()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -636,8 +695,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.redirected()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo('some-url', { state: null }) + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -653,8 +713,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.notHandled()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); @@ -670,8 +731,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.notHandled()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); @@ -687,8 +749,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.notHandled()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -704,8 +767,9 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); - const authenticationResult = await authenticator.authenticate(request); - expect(authenticationResult.notHandled()).toBe(true); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -742,9 +806,10 @@ describe('Authenticator', () => { const request = httpServerMock.createKibanaRequest(); mockSessionStorage.get.mockResolvedValue(null); - const deauthenticationResult = await authenticator.logout(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); - expect(deauthenticationResult.notHandled()).toBe(true); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); @@ -755,12 +820,12 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue(mockSessVal); - const deauthenticationResult = await authenticator.logout(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('some-url') + ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); expect(mockSessionStorage.clear).toHaveBeenCalled(); - expect(deauthenticationResult.redirected()).toBe(true); - expect(deauthenticationResult.redirectURL).toBe('some-url'); }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { @@ -771,21 +836,22 @@ describe('Authenticator', () => { DeauthenticationResult.redirectTo('some-url') ); - const deauthenticationResult = await authenticator.logout(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('some-url') + ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - expect(deauthenticationResult.redirected()).toBe(true); - expect(deauthenticationResult.redirectURL).toBe('some-url'); }); it('returns `notHandled` if session does not exist and provider name is invalid', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); mockSessionStorage.get.mockResolvedValue(null); - const deauthenticationResult = await authenticator.logout(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); - expect(deauthenticationResult.notHandled()).toBe(true); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); @@ -794,11 +860,12 @@ describe('Authenticator', () => { const state = { authorization: 'Bearer xxx' }; mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, state, provider: 'token' }); - const deauthenticationResult = await authenticator.logout(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); - expect(deauthenticationResult.notHandled()).toBe(true); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 5d99fe5ecbf06..4954e1b24216c 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -106,7 +106,6 @@ const providerMap = new Map< [TokenAuthenticationProvider.type, TokenAuthenticationProvider], [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], [PKIAuthenticationProvider.type, PKIAuthenticationProvider], - [HTTPAuthenticationProvider.type, HTTPAuthenticationProvider], ]); function assertRequest(request: KibanaRequest) { @@ -221,6 +220,17 @@ export class Authenticator { ] as [string, BaseAuthenticationProvider]; }) ); + + // For the BWC reasons we always include HTTP authentication provider unless it's explicitly disabled. + if (this.options.config.authc.http.enabled) { + this.setupHTTPAuthenticationProvider( + Object.freeze({ + ...providerCommonOptions, + logger: options.loggers.get(HTTPAuthenticationProvider.type), + }) + ); + } + this.serverBasePath = this.options.basePath.serverBasePath || '/'; this.idleTimeout = this.options.config.session.idleTimeout; @@ -398,6 +408,33 @@ export class Authenticator { return this.providers.has(providerType); } + /** + * Initializes HTTP Authentication provider and appends it to the end of the list of enabled + * authentication providers. + * @param options Common provider options. + */ + private setupHTTPAuthenticationProvider(options: AuthenticationProviderOptions) { + const supportedSchemes = new Set( + this.options.config.authc.http.schemes.map(scheme => scheme.toLowerCase()) + ); + + // If `autoSchemesEnabled` is set we should allow schemes that other providers use to + // authenticate requests with Elasticsearch. + if (this.options.config.authc.http.autoSchemesEnabled) { + for (const provider of this.providers.values()) { + const supportedScheme = provider.getHTTPAuthenticationScheme(); + if (supportedScheme) { + supportedSchemes.add(supportedScheme.toLowerCase()); + } + } + } + + this.providers.set( + HTTPAuthenticationProvider.type, + new HTTPAuthenticationProvider(options, { supportedSchemes }) + ); + } + /** * Returns provider iterator where providers are sorted in the order of priority (based on the session ownership). * @param sessionValue Current session value. diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 5a65c95fd3269..0781608f8bc4c 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -23,6 +23,5 @@ export function mockAuthenticationProviderOptions() { logger: loggingServiceMock.create().get(), basePath, tokens: { refresh: jest.fn(), invalidate: jest.fn() }, - isProviderEnabled: jest.fn(), }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index e98bda51178ce..300e59d9ea3da 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -25,7 +25,6 @@ export interface AuthenticationProviderOptions { client: IClusterClient; logger: Logger; tokens: PublicMethodsOf; - isProviderEnabled: (provider: string) => boolean; } /** @@ -85,6 +84,13 @@ export abstract class BaseAuthenticationProvider { */ abstract logout(request: KibanaRequest, state?: unknown): Promise; + /** + * Returns HTTP authentication scheme that provider uses within `Authorization` HTTP header that + * it attaches to all successfully authenticated requests to Elasticsearch or `null` in case + * provider doesn't attach any additional `Authorization` HTTP headers. + */ + abstract getHTTPAuthenticationScheme(): string | null; + /** * Queries Elasticsearch `_authenticate` endpoint to authenticate request and retrieve the user * information of authenticated user. diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index c8aaadfe6d390..b7bdff0531fc2 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -188,4 +188,8 @@ describe('BasicAuthenticationProvider', () => { ); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('basic'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 75a1439b9b9e5..ad46aff8afa51 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -110,6 +110,14 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { ); } + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'basic'; + } + /** * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 94237f9a3d538..65fbd7cd9f4ad 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -38,28 +38,21 @@ describe('HTTPAuthenticationProvider', () => { it('throws if `schemes` are not specified', () => { const providerOptions = mockAuthenticationProviderOptions(); - expect(() => new HTTPAuthenticationProvider(providerOptions)).toThrowError( + expect(() => new HTTPAuthenticationProvider(providerOptions, undefined as any)).toThrowError( 'Supported schemes should be specified' ); - expect(() => new HTTPAuthenticationProvider(providerOptions, {})).toThrowError( + expect(() => new HTTPAuthenticationProvider(providerOptions, {} as any)).toThrowError( 'Supported schemes should be specified' ); - expect(() => new HTTPAuthenticationProvider(providerOptions, { schemes: [] })).toThrowError( - 'Supported schemes should be specified' - ); - expect( - () => - new HTTPAuthenticationProvider(providerOptions, { schemes: [], autoSchemesEnabled: false }) + () => new HTTPAuthenticationProvider(providerOptions, { supportedSchemes: new Set() }) ).toThrowError('Supported schemes should be specified'); }); describe('`login` method', () => { it('does not handle login', async () => { const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled: true, - schemes: ['apikey'], + supportedSchemes: new Set(['apikey']), }); await expect(provider.login()).resolves.toEqual(AuthenticationResult.notHandled()); @@ -70,95 +63,9 @@ describe('HTTPAuthenticationProvider', () => { }); describe('`authenticate` method', () => { - const testCasesToNotHandle = [ - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['basic'], - header: 'Bearer xxx', - }, - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['bearer'], - header: 'Basic xxx', - }, - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['basic', 'apikey'], - header: 'Bearer xxx', - }, - { - autoSchemesEnabled: true, - isProviderEnabled: () => false, - schemes: ['basic', 'apikey'], - header: 'Bearer xxx', - }, - { - autoSchemesEnabled: true, - isProviderEnabled: (provider: string) => provider === 'basic', - schemes: ['basic'], - header: 'Bearer xxx', - }, - { - autoSchemesEnabled: true, - isProviderEnabled: () => true, - schemes: [], - header: 'ApiKey xxx', - }, - ]; - - const testCasesToHandle = [ - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['basic'], - header: 'Basic xxx', - }, - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['bearer'], - header: 'Bearer xxx', - }, - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['basic', 'apikey'], - header: 'ApiKey xxx', - }, - { - autoSchemesEnabled: false, - isProviderEnabled: () => false, - schemes: ['some-weird-scheme'], - header: 'some-weird-scheme xxx', - }, - ...['saml', 'oidc', 'pki', 'kerberos', 'token'].map(bearerProviderType => ({ - autoSchemesEnabled: true, - isProviderEnabled: (providerType: string) => providerType === bearerProviderType, - schemes: ['apikey'], - header: 'Bearer xxx', - })), - { - autoSchemesEnabled: true, - isProviderEnabled: (provider: string) => provider === 'basic', - schemes: ['apikey'], - header: 'Basic xxx', - }, - { - autoSchemesEnabled: true, - isProviderEnabled: () => true, - schemes: [], - header: 'Bearer xxx', - }, - ]; - it('does not handle authentication for requests without `authorization` header.', async () => { const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled: true, - schemes: ['apikey'], + supportedSchemes: new Set(['apikey']), }); await expect(provider.authenticate(httpServerMock.createKibanaRequest())).resolves.toEqual( @@ -171,9 +78,7 @@ describe('HTTPAuthenticationProvider', () => { it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled: true, - schemes: ['apikey'], + supportedSchemes: new Set(['apikey']), }); await expect( @@ -187,19 +92,16 @@ describe('HTTPAuthenticationProvider', () => { }); it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { - for (const { - isProviderEnabled, - autoSchemesEnabled, - schemes, - header, - } of testCasesToNotHandle) { + for (const { schemes, header } of [ + { schemes: ['basic'], header: 'Bearer xxx' }, + { schemes: ['bearer'], header: 'Basic xxx' }, + { schemes: ['basic', 'apikey'], header: 'Bearer xxx' }, + { schemes: ['basic', 'bearer'], header: 'ApiKey xxx' }, + ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); - mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled, - schemes, + supportedSchemes: new Set(schemes), }); await expect(provider.authenticate(request)).resolves.toEqual( @@ -215,7 +117,13 @@ describe('HTTPAuthenticationProvider', () => { it('succeeds if authentication via `authorization` header with supported scheme succeeds.', async () => { const user = mockAuthenticatedUser(); - for (const { isProviderEnabled, autoSchemesEnabled, schemes, header } of testCasesToHandle) { + for (const { schemes, header } of [ + { schemes: ['basic'], header: 'Basic xxx' }, + { schemes: ['bearer'], header: 'Bearer xxx' }, + { schemes: ['basic', 'apikey'], header: 'ApiKey xxx' }, + { schemes: ['some-weird-scheme'], header: 'some-weird-scheme xxx' }, + { schemes: ['apikey', 'bearer'], header: 'Bearer xxx' }, + ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -223,11 +131,8 @@ describe('HTTPAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); - mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled, - schemes, + supportedSchemes: new Set(schemes), }); await expect(provider.authenticate(request)).resolves.toEqual( @@ -242,7 +147,13 @@ describe('HTTPAuthenticationProvider', () => { it('fails if authentication via `authorization` header with supported scheme fails.', async () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - for (const { isProviderEnabled, autoSchemesEnabled, schemes, header } of testCasesToHandle) { + for (const { schemes, header } of [ + { schemes: ['basic'], header: 'Basic xxx' }, + { schemes: ['bearer'], header: 'Bearer xxx' }, + { schemes: ['basic', 'apikey'], header: 'ApiKey xxx' }, + { schemes: ['some-weird-scheme'], header: 'some-weird-scheme xxx' }, + { schemes: ['apikey', 'bearer'], header: 'Bearer xxx' }, + ]) { const request = httpServerMock.createKibanaRequest({ headers: { authorization: header } }); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -250,11 +161,8 @@ describe('HTTPAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.asScoped.mockClear(); - mockOptions.isProviderEnabled.mockImplementation(isProviderEnabled); const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled, - schemes, + supportedSchemes: new Set(schemes), }); await expect(provider.authenticate(request)).resolves.toEqual( @@ -271,9 +179,7 @@ describe('HTTPAuthenticationProvider', () => { describe('`logout` method', () => { it('does not handle logout', async () => { const provider = new HTTPAuthenticationProvider(mockOptions, { - enabled: true, - autoSchemesEnabled: true, - schemes: ['apikey'], + supportedSchemes: new Set(['apikey']), }); await expect(provider.logout()).resolves.toEqual(DeauthenticationResult.notHandled()); @@ -282,4 +188,11 @@ describe('HTTPAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(['apikey']), + }); + expect(provider.getHTTPAuthenticationScheme()).toBeNull(); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/http.ts b/x-pack/plugins/security/server/authentication/providers/http.ts index c06958ad7f350..57163bf8145b8 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -11,9 +11,7 @@ import { getHTTPAuthenticationScheme } from '../get_http_authentication_scheme'; import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; interface HTTPAuthenticationProviderOptions { - enabled: boolean; - autoSchemesEnabled: boolean; - schemes: string[]; + supportedSchemes: Set; } /** @@ -31,26 +29,16 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { */ private readonly supportedSchemes: Set; - /** - * Indicates whether we should allow schemes that other providers use to authenticate with - * Elasticsearch. - */ - private readonly autoSchemesEnabled: boolean; - constructor( protected readonly options: Readonly, - httpOptions?: Readonly> + httpOptions: Readonly ) { super(options); - this.supportedSchemes = new Set( - (httpOptions?.schemes ?? []).map(scheme => scheme.toLowerCase()) - ); - this.autoSchemesEnabled = httpOptions?.autoSchemesEnabled ?? false; - - if (this.supportedSchemes.size === 0 && !this.autoSchemesEnabled) { + if ((httpOptions?.supportedSchemes?.size ?? 0) === 0) { throw new Error('Supported schemes should be specified'); } + this.supportedSchemes = httpOptions.supportedSchemes; } /** @@ -74,7 +62,7 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } - if (!this.isSchemeSupported(authenticationScheme)) { + if (!this.supportedSchemes.has(authenticationScheme)) { this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`); return AuthenticationResult.notHandled(); } @@ -102,31 +90,10 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Checks whether specified scheme should be supported by the provider based on the explicitly - * specified schemes in Kibana configuration or currently enabled authentication providers (if - * `xpack.security.authc.http.autoSchemesEnabled` is set to `true`). - * @param scheme + * Returns `null` since provider doesn't attach any additional `Authorization` HTTP headers to + * successfully authenticated requests to Elasticsearch. */ - private isSchemeSupported(scheme: string) { - const isSchemeSupported = this.supportedSchemes.has(scheme); - if (isSchemeSupported || !this.autoSchemesEnabled) { - return isSchemeSupported; - } - - if (scheme === 'basic') { - return this.options.isProviderEnabled('basic'); - } - - if (scheme === 'bearer') { - return ( - this.options.isProviderEnabled('saml') || - this.options.isProviderEnabled('oidc') || - this.options.isProviderEnabled('pki') || - this.options.isProviderEnabled('kerberos') || - this.options.isProviderEnabled('token') - ); - } - - return false; + public getHTTPAuthenticationScheme() { + return null; } } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 2f0431c98a295..51fb961482e83 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -501,4 +501,8 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('bearer'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 9ae35cfe75901..b6474a5e1d471 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -94,6 +94,14 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(`${this.options.basePath.serverBasePath}/logged_out`); } + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'bearer'; + } + /** * Tries to authenticate request with `Negotiate ***` Authorization header by passing it to the Elasticsearch backend to * get an access token in exchange. diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index c2fc4b8a10aff..51a25825bf985 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -603,4 +603,8 @@ describe('OIDCAuthenticationProvider', () => { }); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('bearer'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index d6f4bf37001d3..c6b504e722adf 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -402,4 +402,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.failed(err); } } + + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'bearer'; + } } diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 3b5fa1bfa4d39..efc286c6c895f 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -518,4 +518,8 @@ describe('PKIAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ accessToken: 'foo' }); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('bearer'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 0fec62317c802..854f92a50fa9d 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -101,6 +101,14 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return DeauthenticationResult.redirectTo(`${this.options.basePath.serverBasePath}/logged_out`); } + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'bearer'; + } + /** * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index cbdcfa0f0b025..d97a6c0838b86 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -1099,4 +1099,8 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('bearer'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index a7dbb14903479..1ac59d66a2235 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -239,6 +239,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } } + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'bearer'; + } + /** * Validates whether request payload contains `SAMLResponse` parameter that can be exchanged * to a proper access token. If state is presented and includes request id then it means diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index b1efe0a0475b4..e81d14e8bf9f3 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -443,4 +443,8 @@ describe('TokenAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe('bearer'); + }); }); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 35d0aa9cc1816..fffac254ed30a 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -134,6 +134,14 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { ); } + /** + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return 'bearer'; + } + /** * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 19110cc50da9b..64c695670fa19 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -101,20 +101,6 @@ describe('config schema', () => { ); }); - it('should throw error if `http` provider is explicitly set in xpack.security.authc.providers', () => { - expect(() => - ConfigSchema.validate({ authc: { providers: ['http'] } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc]: \`http\` authentication provider cannot be specified in \`xpack.security.authc.providers\`. Use \`xpack.security.authc.http.enabled\` instead."` - ); - - expect(() => - ConfigSchema.validate({ authc: { providers: ['basic', 'http'] } }) - ).toThrowErrorMatchingInlineSnapshot( - `"[authc]: \`http\` authentication provider cannot be specified in \`xpack.security.authc.providers\`. Use \`xpack.security.authc.http.enabled\` instead."` - ); - }); - describe('authc.oidc', () => { it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => { expect(() => @@ -368,40 +354,4 @@ describe('createConfig$()', () => { expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); }); - - it('should include `http` authentication provider by default', async () => { - let config = (await mockAndCreateConfig(true, {})).config; - expect(config.authc.providers).toEqual(['basic', 'http']); - - config = ( - await mockAndCreateConfig(true, { authc: { providers: ['saml'], saml: { realm: 'saml1' } } }) - ).config; - expect(config.authc.providers).toEqual(['saml', 'http']); - - config = ( - await mockAndCreateConfig(true, { - authc: { providers: ['saml', 'basic'], saml: { realm: 'saml1' } }, - }) - ).config; - expect(config.authc.providers).toEqual(['saml', 'basic', 'http']); - }); - - it('should not include `http` authentication provider if it is disabled', async () => { - let config = (await mockAndCreateConfig(true, { authc: { http: { enabled: false } } })).config; - expect(config.authc.providers).toEqual(['basic']); - - config = ( - await mockAndCreateConfig(true, { - authc: { providers: ['saml'], saml: { realm: 'saml1' }, http: { enabled: false } }, - }) - ).config; - expect(config.authc.providers).toEqual(['saml']); - - config = ( - await mockAndCreateConfig(true, { - authc: { providers: ['saml', 'basic'], saml: { realm: 'saml1' }, http: { enabled: false } }, - }) - ).config; - expect(config.authc.providers).toEqual(['saml', 'basic']); - }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index f5e0914245097..8663a6e61c203 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -39,31 +39,22 @@ export const ConfigSchema = schema.object( lifespan: schema.nullable(schema.duration()), }), secureCookies: schema.boolean({ defaultValue: false }), - authc: schema.object( - { - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), - oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), - saml: providerOptionsSchema( - 'saml', - schema.object({ - realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), - }) - ), - http: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - autoSchemesEnabled: schema.boolean({ defaultValue: true }), - schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), - }), - }, - { - validate(value) { - if (value.providers.includes('http')) { - return '`http` authentication provider cannot be specified in `xpack.security.authc.providers`. Use `xpack.security.authc.http.enabled` instead.'; - } - }, - } - ), + authc: schema.object({ + providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), + saml: providerOptionsSchema( + 'saml', + schema.object({ + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) + ), + http: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + autoSchemesEnabled: schema.boolean({ defaultValue: true }), + schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), + }), + }), }, // This option should be removed as soon as we entirely migrate config from legacy Security plugin. { allowUnknowns: true } @@ -100,11 +91,6 @@ export function createConfig$(context: PluginInitializerContext, isTLSEnabled: b secureCookies = true; } - // For the BWC reasons we always include HTTP authentication provider unless it's explicitly disabled. - if (config.authc.http.enabled) { - config.authc.providers.push('http'); - } - return { ...config, encryptionKey,