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..af019ff10dedc 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -5,6 +5,8 @@ */ jest.mock('./providers/basic'); +jest.mock('./providers/saml'); +jest.mock('./providers/http'); import Boom from 'boom'; import { duration, Duration } from 'moment'; @@ -23,15 +25,27 @@ import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenti import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider } from './providers'; -function getMockOptions(config: Partial = {}) { +function getMockOptions({ + session, + providers, + http = {}, +}: { + session?: AuthenticatorOptions['config']['session']; + providers?: string[]; + http?: Partial; +} = {}) { 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: ['apikey'], ...http }, + }, }, sessionStorageFactory: sessionStorageMock.createFactory(), }; @@ -44,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); @@ -55,23 +74,80 @@ 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.' ); }); + + 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', () => { @@ -80,9 +156,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 = { @@ -124,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 () => { @@ -140,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 () => { @@ -158,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({ @@ -174,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 () => { @@ -189,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, @@ -214,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(); @@ -232,9 +288,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 = { @@ -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); @@ -377,7 +430,7 @@ describe('Authenticator', () => { idleTimeout: duration(3600 * 24), lifespan: null, }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -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({ @@ -416,7 +469,7 @@ describe('Authenticator', () => { idleTimeout: duration(hr * 2), lifespan: duration(hr * 8), }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -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({ @@ -468,7 +521,7 @@ describe('Authenticator', () => { idleTimeout: null, lifespan, }, - authc: { providers: ['basic'], oidc: {}, saml: {} }, + providers: ['basic'], }); mockSessionStorage = sessionStorageMock.create(); @@ -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(); @@ -718,9 +782,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 = { @@ -744,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(); }); @@ -757,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 () => { @@ -773,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(); }); @@ -796,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); }); }); @@ -809,9 +874,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 +914,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..4954e1b24216c 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'; @@ -191,6 +192,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 +208,8 @@ export class Authenticator { ? (this.options.config.authc as Record)[providerType] : undefined; + this.logger.debug(`Enabling "${providerType}" authentication provider.`); + return [ providerType, instantiateProvider( @@ -216,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; @@ -385,6 +400,41 @@ 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); + } + + /** + * 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/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..0781608f8bc4c 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'); diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index a40732768810d..300e59d9ea3da 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -84,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 f9c665d6cea48..b7bdff0531fc2 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -4,18 +4,31 @@ * 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 { 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; @@ -30,38 +43,39 @@ 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 authenticationResult = await provider.login( - httpServerMock.createKibanaRequest(), - credentials + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} }), credentials) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: { authorization }, + state: { authorization }, + }) ); - 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 () => { - 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); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + 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); }); }); @@ -69,142 +83,113 @@ 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('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); - - const authenticationResult = await provider.authenticate(request); + it('does not handle authentication via `authorization` header.', async () => { + const authorization = generateAuthorizationHeader('user', 'password'); + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - // Session state and authHeaders aren't returned for header-based auth. - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); }); - it('succeeds if only state is available.', async () => { - const request = httpServerMock.createKibanaRequest(); - const user = mockAuthenticatedUser(); + 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 } }); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); - - const authenticationResult = await provider.authenticate(request, { authorization }); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toEqual({ authorization }); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); }); - it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer ***' }, - }); + it('succeeds if only state is available.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const user = mockAuthenticatedUser(); const authorization = generateAuthorizationHeader('user', 'password'); - const authenticationResult = await provider.authenticate(request, { authorization }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Bearer ***'); - expect(authenticationResult.notHandled()).toBe(true); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); }); 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 authenticationResult = await provider.authenticate(request, { 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); - }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - 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); + await expect(provider.authenticate(request, { authorization })).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - const authorizationInState = generateAuthorizationHeader('user1', 'password2'); - const authenticationResult = await provider.authenticate(request, { - authorization: authorizationInState, - }); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual(user); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.authHeaders).toBeUndefined(); + expect(request.headers).not.toHaveProperty('authorization'); }); }); 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') ); }); }); + + 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 a8e4e8705a7a8..ad46aff8afa51 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(); } /** @@ -114,37 +111,11 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Validates whether request contains `Basic ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. */ - 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) }; - } + public getHTTPAuthenticationScheme() { + return 'basic'; } /** 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..65fbd7cd9f4ad --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -0,0 +1,198 @@ +/* + * 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, + 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(() => { + mockOptions = mockAuthenticationProviderOptions(); + }); + + it('throws if `schemes` are not specified', () => { + const providerOptions = mockAuthenticationProviderOptions(); + + expect(() => new HTTPAuthenticationProvider(providerOptions, undefined as any)).toThrowError( + 'Supported schemes should be specified' + ); + expect(() => new HTTPAuthenticationProvider(providerOptions, {} as any)).toThrowError( + 'Supported schemes should be specified' + ); + expect( + () => 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, { + supportedSchemes: new Set(['apikey']), + }); + + await expect(provider.login()).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + }); + + describe('`authenticate` method', () => { + it('does not handle authentication for requests without `authorization` header.', async () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(['apikey']), + }); + + await expect(provider.authenticate(httpServerMock.createKibanaRequest())).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('does not handle authentication for requests with empty scheme in `authorization` header.', async () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(['apikey']), + }); + + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { authorization: '' } }) + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('does not handle authentication via `authorization` header if scheme is not supported.', async () => { + 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 } }); + + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(schemes), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(request.headers.authorization).toBe(header); + } + + 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 { 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(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.asScoped.mockClear(); + + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(schemes), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded({ ...user, authentication_provider: 'http' }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); + + 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 { 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(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.asScoped.mockClear(); + + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(schemes), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization: header } }); + + expect(request.headers.authorization).toBe(header); + } + }); + }); + + describe('`logout` method', () => { + it('does not handle logout', async () => { + const provider = new HTTPAuthenticationProvider(mockOptions, { + supportedSchemes: new Set(['apikey']), + }); + + await expect(provider.logout()).resolves.toEqual(DeauthenticationResult.notHandled()); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + 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 new file mode 100644 index 0000000000000..57163bf8145b8 --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/http.ts @@ -0,0 +1,99 @@ +/* + * 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 { + supportedSchemes: Set; +} + +/** + * 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; + + constructor( + protected readonly options: Readonly, + httpOptions: Readonly + ) { + super(options); + + if ((httpOptions?.supportedSchemes?.size ?? 0) === 0) { + throw new Error('Supported schemes should be specified'); + } + this.supportedSchemes = httpOptions.supportedSchemes; + } + + /** + * 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.supportedSchemes.has(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(); + } + + /** + * Returns `null` since provider doesn't attach any additional `Authorization` HTTP headers to + * successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return null; + } +} 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..51fb961482e83 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,31 @@ 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, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { KerberosAuthenticationProvider } from './kerberos'; -import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server/elasticsearch'; + +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; @@ -28,104 +41,128 @@ 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: 'Bearer some-token' }, + }); + + 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'); + }); + + 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: 'Basic some:credentials' }, + headers: { authorization: 'Bearer some-token' }, }); const tokenPair = { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; - const authenticationResult = await provider.authenticate(request, tokenPair); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - sinon.assert.notCalled(mockOptions.client.asScoped); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - expect(request.headers.authorization).toBe('Basic some:credentials'); - expect(authenticationResult.notHandled()).toBe(true); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); }); 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 authenticationResult = await provider.authenticate(request, null); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - expect(authenticationResult.notHandled()).toBe(true); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); }); 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); + + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.notHandled() + ); - const authenticationResult = await provider.authenticate(request, null); - expect(authenticationResult.notHandled()).toBe(true); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); }); it('fails if state is present, but backend does not support Kerberos.', async () => { 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 failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); }); 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')}` }, + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects( - ElasticsearchErrorHelpers.decorateNotAuthorizedError( - new (errors.AuthenticationException as any)('Unauthorized', { - body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, - }) - ) - ); - - const authenticationResult = await provider.authenticate(request, null); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toHaveProperty('output.statusCode', 401); - expect(authenticationResult.authResponseHeaders).toEqual({ 'WWW-Authenticate': 'Negotiate' }); + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, + }); }); 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 request = httpServerMock.createKibanaRequest({ headers: {} }); + + const failureReason = new errors.ServiceUnavailable(); + 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) + ); - 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 () => { @@ -134,34 +171,33 @@ describe('KerberosAuthenticationProvider', () => { headers: { authorization: 'negotiate spnego' }, }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer some-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + 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', + }); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' }); + 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' }, + } + ) + ); - const authenticationResult = await provider.authenticate(request); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer some-token' }, + }); - 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.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 () => { @@ -170,38 +206,35 @@ 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', }); - const authenticationResult = await provider.authenticate(request); - - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + 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(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', + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer some-token' }, }); - expect(authenticationResult.state).toEqual({ - accessToken: 'some-token', - refreshToken: 'some-refresh-token', + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); + + expect(request.headers.authorization).toBe('negotiate spnego'); }); it('fails with `Negotiate response-token` if cannot complete context with a response token.', async () => { @@ -214,24 +247,19 @@ 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' } } + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized(), { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, + }) ); - 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', + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); + + expect(request.headers.authorization).toBe('negotiate spnego'); }); it('fails with `Negotiate` if cannot create context using provided SPNEGO token.', async () => { @@ -244,24 +272,19 @@ describe('KerberosAuthenticationProvider', () => { body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, }) ); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized(), { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) ); - 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', + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, }); + + expect(request.headers.authorization).toBe('negotiate spnego'); }); it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => { @@ -270,22 +293,17 @@ describe('KerberosAuthenticationProvider', () => { }); const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser - .withArgs('shield.getAccessToken') - .rejects(failureReason); - - const authenticationResult = await provider.authenticate(request); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + 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 () => { @@ -294,51 +312,52 @@ 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 authenticationResult = await provider.authenticate(request); - - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.getAccessToken', - { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } } + 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', + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(failureReason) ); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer some-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).toBe(failureReason); - expect(authenticationResult.authResponseHeaders).toBeUndefined(); }); 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); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { authHeaders: { authorization } } + ) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + 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 () => { @@ -346,149 +365,94 @@ 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); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'kerberos' }, + { + authHeaders: { authorization: 'Bearer newfoo' }, + state: { accessToken: 'newfoo', refreshToken: 'newbar' }, + } + ) + ); - 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' }); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'kerberos' }); - expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); expect(request.headers).not.toHaveProperty('authorization'); }); 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}` } }) + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) ); - scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').rejects(failureReason); - const authenticationResult = await provider.authenticate(request, tokenPair); + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: `Bearer ${tokenPair.accessToken}` }, + }); + + 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 authenticationResult = await provider.authenticate(request, tokenPair); - - 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); + 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); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); - }); + mockOptions.tokens.refresh.mockResolvedValue(null); - 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', - }; + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); - 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); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); }); }); @@ -496,13 +460,13 @@ 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() + ); - sinon.assert.notCalled(mockOptions.tokens.invalidate); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); it('fails if `tokens.invalidate` fails', async () => { @@ -510,15 +474,14 @@ describe('KerberosAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - - const authenticationResult = await provider.logout(request, tokenPair); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => { @@ -528,15 +491,18 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'some-valid-refresh-token', }; - mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.logout(request, tokenPair); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/mock-server-basepath/logged_out'); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + 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 b8e3b7bc23790..b6474a5e1d471 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); @@ -115,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. @@ -201,26 +188,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..51a25825bf985 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -4,20 +4,34 @@ * 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, + 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; @@ -44,7 +58,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: @@ -56,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/' } } + ) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + 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,18 +103,24 @@ 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' }); - - const authenticationResult = await provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/some-path', + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: 'some-token', + refresh_token: 'some-refresh-token', }); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + 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', { body: { @@ -114,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', - }); - - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - 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', - }); - - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - 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, {}); - - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + 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(authenticationResult.failed()).toBe(true); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if authentication response is not valid.', async () => { @@ -174,18 +185,17 @@ 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)); - - const authenticationResult = await provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/base-path/some-path', - }); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + 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', { body: { @@ -196,9 +206,6 @@ describe('OIDCAuthenticationProvider', () => { }, } ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); } @@ -234,16 +241,15 @@ 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 () => { 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: @@ -254,84 +260,99 @@ 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', + }, + } + ) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + 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 () => { 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); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { + 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 () => { 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); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'oidc' }, + { authHeaders: { authorization } } + ) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + 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 `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, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + 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' }, }); - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Basic some:credentials'); - expect(authenticationResult.notHandled()).toBe(true); + 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'); }); 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,15 +360,17 @@ 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); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + 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 () => { @@ -355,67 +378,80 @@ 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 }); + 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'); + }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); - mockOptions.tokens.refresh - .withArgs(tokenPair.refreshToken) - .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + 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' }, + } + ) + ); - 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({ - 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 () => { - 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); + 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); + + 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 () => { - 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,115 +462,71 @@ 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); + 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', + }, + } + ) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', { - body: { realm: `oidc1` }, + 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(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', + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: `oidc1` }, }); }); 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 }); - - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); - - const authenticationResult = await provider.authenticate(request, tokenPair); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - }); - - 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); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const authenticationResult = await provider.authenticate(request); + mockOptions.tokens.refresh.mockResolvedValue(null); - 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); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); - const authenticationResult = await provider.authenticate(request); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - 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', + expectAuthenticateCall(mockOptions.client, { + headers: { 'kbn-xsrf': 'xsrf', authorization }, }); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + expect(request.headers).not.toHaveProperty('authorization'); }); }); @@ -542,16 +534,19 @@ 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() + ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if OpenID Connect logout call fails.', async () => { @@ -560,22 +555,16 @@ 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, - }); + await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); - 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 }, }); - - 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 () => { @@ -583,22 +572,16 @@ 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, - }); + await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') + ); - 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 }, }); - - 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 () => { @@ -606,18 +589,22 @@ 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' }); - - const authenticationResult = await provider.logout(request, { - accessToken, - refreshToken, + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/logout&id_token_hint=thehint', }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint'); + 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 }, + }); }); }); + + 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 f13a2ec05231a..c6b504e722adf 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. @@ -444,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 a2dda88c4680c..efc286c6c895f 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -7,23 +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 { - MockAuthenticationProviderOptionsWithJest, - mockAuthenticationProviderOptionsWithJest, -} from './base.mock'; +import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } from './base.mock'; -import { PKIAuthenticationProvider } from './pki'; import { ElasticsearchErrorHelpers, - ScopedClusterClient, -} from '../../../../../../src/core/server/elasticsearch'; -import { Socket } from 'net'; -import { getErrorStatusCode } from '../../errors'; + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { PKIAuthenticationProvider } from './pki'; interface MockPeerCertificate extends Partial { issuerCertificate: MockPeerCertificate; @@ -62,32 +62,59 @@ 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: 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: 'Bearer some-token' }, + }); + + 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'); + }); + + 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 state = { accessToken: 'some-valid-token', 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('Basic some:credentials'); - expect(authenticationResult.notHandled()).toBe(true); + expect(request.headers.authorization).toBe('Bearer some-token'); }); it('does not handle requests without certificate.', async () => { @@ -95,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(); }); @@ -107,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(); }); @@ -121,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(); }); @@ -134,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({ @@ -151,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({ @@ -174,12 +199,18 @@ 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); + 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', { @@ -191,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 () => { @@ -221,34 +241,29 @@ 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); + 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 () => { @@ -263,12 +278,18 @@ 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); + 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({ @@ -286,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 () => { @@ -312,12 +325,18 @@ 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); + 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', { @@ -330,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 () => { @@ -348,18 +359,15 @@ 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); + 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 () => { @@ -373,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', { @@ -381,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,35 +405,30 @@ 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); + 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), @@ -435,110 +437,42 @@ 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); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'pki' }, + { authHeaders: { authorization: `Bearer ${state.accessToken}` } } + ) ); - const authenticationResult = await provider.authenticate(request, state); + 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 mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(new errors.ServiceUnavailable()); - 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).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 failureReason = new errors.ServiceUnavailable(); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue( - (mockScopedClusterClient as unknown) as jest.Mocked - ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - 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 + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(failureReason) ); - const authenticationResult = await provider.authenticate(request, state); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer token' } }); }); }); @@ -546,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(); }); @@ -562,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 () => { @@ -577,13 +510,16 @@ 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'); }); }); + + 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 6d5aa9f01f2ea..854f92a50fa9d 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 @@ -120,23 +102,11 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend. - * @param request Request instance. + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. */ - 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); - } + public getHTTPAuthenticationScheme() { + return 'bearer'; } /** 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..d97a6c0838b86 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,19 +5,33 @@ */ 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 { + ElasticsearchErrorHelpers, + IClusterClient, + ScopeableRequest, +} from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; +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; let mockOptions: MockAuthenticationProviderOptions; @@ -63,294 +77,317 @@ 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', }); - 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', + }, + }) ); - 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' } } ); - - 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.') + ) ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('SAML response state does not have corresponding request id.') - ); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); 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', }); - 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', + }, + }) ); - 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' } } ); - - 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 () => { 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', }); - 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', + }, + }) + ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + 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 () => { const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('SAML response is stale!'); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlAuthenticate') - .rejects(failureReason); - - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } - ); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + 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)); - 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' } } ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); 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); - - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + 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)); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', { - username: 'user', - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, } ); - - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlAuthenticate', - { 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 () => { - 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, - { 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)); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + 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, }); - - 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 () => { - 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(); - - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - state + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + 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', + }, + }) ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + 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, }); - - 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 () => { - 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); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(existingUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.withArgs('shield.samlAuthenticate').resolves({ + mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'new-user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', }); - mockOptions.tokens.invalidate.resolves(); - - const authenticationResult = await provider.login( - request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - state + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + 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', + }, + }) ); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + 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, }); - - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/overwritten_session'); }); }); @@ -358,170 +395,171 @@ 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', - }); - - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('State does not include URL path to redirect to.') + 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(); }); 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' } - ); - - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); - - expect(authenticationResult.notHandled()).toBe(true); + 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(); }); 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', }); - 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', + }, + } + ) ); - 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(); - - 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 () => { 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', }); - 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', + }, + } + ) ); - 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( '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 () => { 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', }); - 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', + }, + } + ) ); - 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( '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 () => { const request = httpServerMock.createKibanaRequest(); const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.withArgs('shield.samlPrepare').rejects(failureReason); - - const authenticationResult = await provider.login( - request, - { - step: SAMLLoginStep.RedirectURLFragmentCaptured, - redirectURLFragment: '#some-fragment', - }, - { redirectURL: '/test-base-path/some-path' } - ); - - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlPrepare', - { body: { realm: 'test-realm' } } - ); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + 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' }, + }); }); }); }); @@ -530,44 +568,57 @@ 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 `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, { - username: 'user', - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + 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' }, }); - sinon.assert.notCalled(mockOptions.client.asScoped); - expect(request.headers.authorization).toBe('Basic some:credentials'); - expect(authenticationResult.notHandled()).toBe(true); + 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'); }); 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(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe( - '/mock-server-basepath/api/security/saml/capture-url-fragment' + 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(authenticationResult.state).toEqual({ redirectURL: '/base-path/s/foo/some-path' }); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('redirects non-AJAX request that can not be authenticated to the IdP if request path is too large.', async () => { @@ -575,14 +626,19 @@ 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); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '' } } + ) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -590,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 () => { @@ -604,21 +654,20 @@ 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); + await expect(provider.authenticate(request, null)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + 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 () => { const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { username: 'user', accessToken: 'some-valid-token', @@ -626,40 +675,43 @@ 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); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'saml' }, + { authHeaders: { authorization } } + ) + ); - const authenticationResult = await provider.authenticate(request, state); + 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 () => { - 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); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(failureReason as any) + ); - const authenticationResult = await provider.authenticate(request, state); + 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 () => { @@ -671,65 +723,80 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'valid-refresh-token', }; - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${state.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + 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'); + }); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: 'Bearer new-access-token' } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + }); - mockOptions.tokens.refresh - .withArgs(state.refreshToken) - .resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }); + 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', + }, + } + ) + ); - 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({ - 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 () => { - 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); + 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); + + 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 () => { @@ -739,80 +806,100 @@ 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.mockResolvedValue(null); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); - mockOptions.tokens.refresh.withArgs(state.refreshToken).resolves(null); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - const authenticationResult = await provider.authenticate(request, state); + expectAuthenticateCall(mockOptions.client, { + headers: { 'kbn-xsrf': 'xsrf', authorization }, + }); 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 () => { - 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); + 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' } } + ) + ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - 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' }); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); 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); + 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); - sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.samlPrepare', { + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' }, }); @@ -820,71 +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: '' }); - }); - - 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); }); }); @@ -892,16 +914,15 @@ 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() + ); - sinon.assert.notCalled(mockOptions.client.callAsInternalUser); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails if SAML logout call fails.', async () => { @@ -910,42 +931,32 @@ 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', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); - 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 }, }); - - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); }); it('fails if SAML invalidate call fails.', async () => { 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' } } + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, + }); }); it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => { @@ -953,23 +964,16 @@ 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', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); - 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 }, }); - - 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 () => { @@ -977,23 +981,16 @@ 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', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); - 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 }, }); - - 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 () => { @@ -1003,87 +1000,65 @@ 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', - accessToken, - refreshToken, - }); + await expect( + provider.logout(request, { username: 'user', accessToken, refreshToken }) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out')); - 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 }, }); - - 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 () => { const request = httpServerMock.createKibanaRequest({ query: { SAMLRequest: 'xxx yyy' } }); - mockOptions.client.callAsInternalUser - .withArgs('shield.samlInvalidate') - .resolves({ redirect: null }); - - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - }); + mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - sinon.assert.calledWithExactly( - mockOptions.client.callAsInternalUser, - 'shield.samlInvalidate', - { body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' } } - ); + 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(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/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' }, + }); }); 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' } } + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') ); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/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' }, + }); }); 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' } } + await expect(provider.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/logged_out') ); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/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' }, + }); }); it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => { @@ -1091,37 +1066,41 @@ 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' }); - - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken, - refreshToken, + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); + 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); }); 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' }); - - const authenticationResult = await provider.logout(request, { - username: 'user', - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', + mockOptions.client.callAsInternalUser.mockResolvedValue({ + redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H', }); - sinon.assert.calledOnce(mockOptions.client.callAsInternalUser); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H'); + 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); }); }); + + 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 a817159fcd445..1ac59d66a2235 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() && @@ -243,37 +240,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. */ - 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) }; - } + public getHTTPAuthenticationScheme() { + return 'bearer'; } /** 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..e81d14e8bf9f3 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -6,18 +6,32 @@ 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, + 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; @@ -28,29 +42,35 @@ 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); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + access_token: tokenPair.accessToken, + refresh_token: tokenPair.refreshToken, + }); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { authHeaders: { authorization }, state: tokenPair } + ) + ); - const authenticationResult = await provider.login(request, credentials); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.state).toEqual(tokenPair); - expect(authenticationResult.authHeaders).toEqual({ authorization }); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { + body: { grant_type: 'password', ...credentials }, + }); }); it('fails if token cannot be generated during login attempt', async () => { @@ -58,109 +78,125 @@ 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); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - sinon.assert.notCalled(mockOptions.client.asScoped); + 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(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); 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); + await expect(provider.login(request, credentials)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + 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(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); }); describe('`authenticate` method', () => { - 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); - }); + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); - 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 + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() ); - 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' - ); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); }); - 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(); + it('does not handle authentication via `authorization` header even if state contains valid credentials.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); - mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } })) - .callAsCurrentUser.withArgs('shield.authenticate') - .resolves(user); + await expect( + provider.authenticate(request, { accessToken: 'foo', refreshToken: 'bar' }) + ).resolves.toEqual(AuthenticationResult.notHandled()); - const authenticationResult = await provider.authenticate(request); + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); - expect(authenticationResult.succeeded()).toBe(true); - expect(authenticationResult.user).toEqual({ ...user, authentication_provider: 'token' }); - expect(authenticationResult.authHeaders).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); + 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. + 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 () => { + 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('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); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { authHeaders: { authorization } } + ) + ); + + 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'); }); @@ -169,162 +205,115 @@ 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); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: 'token' }, + { + authHeaders: { authorization: 'Bearer newfoo' }, + state: { accessToken: 'newfoo', refreshToken: 'newbar' }, + } + ) + ); - 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' }); - expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' }); - expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' }); 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 ***' }, - }); + it('fails if authentication with token from state fails with unknown error.', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest({ headers: {} }); 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); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - 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 authenticationError = new errors.InternalServerError('something went wrong'); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects(authenticationError); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + 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 () => { - 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); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(refreshError) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + 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 () => { - 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.mockResolvedValue(null); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/login?next=%2Fbase-path%2Fsome-path', { + state: null, + }) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + 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 () => { @@ -333,61 +322,66 @@ describe('TokenAuthenticationProvider', () => { path: '/some-path', }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockScopedClusterClient( - mockOptions.client, - sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }) - ) - .callAsCurrentUser.withArgs('shield.authenticate') - .rejects({ statusCode: 401 }); + mockOptions.tokens.refresh.mockResolvedValue(null); - mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null); + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); - const authenticationResult = await provider.authenticate(request, tokenPair); + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - sinon.assert.calledOnce(mockOptions.tokens.refresh); + expectAuthenticateCall(mockOptions.client, { + headers: { 'kbn-xsrf': 'xsrf', authorization }, + }); 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 () => { 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'); + }); - const authenticationResult = await provider.authenticate(request, tokenPair); + mockOptions.tokens.refresh.mockResolvedValue({ + accessToken: 'newfoo', + refreshToken: 'newbar', + }); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); - 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); - expect(authenticationResult.user).toBeUndefined(); - expect(authenticationResult.state).toBeUndefined(); - expect(authenticationResult.error).toEqual(authenticationError); }); }); @@ -395,13 +389,15 @@ 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') + ); - sinon.assert.notCalled(mockOptions.tokens.invalidate); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); it('fails if `tokens.invalidate` fails', async () => { @@ -409,45 +405,46 @@ describe('TokenAuthenticationProvider', () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; const failureReason = new Error('failed to delete token'); - mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason); - - const authenticationResult = await provider.logout(request, tokenPair); + mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.failed(failureReason) + ); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toBe(failureReason); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); it('redirects to /login if tokens are invalidated successfully', async () => { 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); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + ); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT'); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); }); it('redirects to /login with optional search parameters if tokens are invalidated successfully', async () => { const request = httpServerMock.createKibanaRequest({ query: { yep: 'nope' } }); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - mockOptions.tokens.invalidate.withArgs(tokenPair).resolves(); - - const authenticationResult = await provider.logout(request, tokenPair); + mockOptions.tokens.invalidate.mockResolvedValue(undefined); - sinon.assert.calledOnce(mockOptions.tokens.invalidate); - sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair); + await expect(provider.logout(request, tokenPair)).resolves.toEqual( + DeauthenticationResult.redirectTo('/base-path/login?yep=nope') + ); - expect(authenticationResult.redirected()).toBe(true); - expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope'); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + 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 03fd003e2cbde..fffac254ed30a 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)); } @@ -144,37 +135,11 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Validates whether request contains `Bearer ***` Authorization header and just passes it - * forward to Elasticsearch backend. - * @param request Request instance. + * Returns HTTP authentication scheme (`Bearer`) that's used within `Authorization` HTTP header + * that provider attaches to all successfully authenticated requests to Elasticsearch. */ - 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) }; - } + public getHTTPAuthenticationScheme() { + return 'bearer'; } /** diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index f7374eedb5520..64c695670fa19 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', () => { @@ -101,15 +122,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 +154,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 +199,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 () => { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index db8c48f314d7c..8663a6e61c203 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -49,6 +49,11 @@ export const ConfigSchema = schema.object( 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. 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 })