From 0d1e2cb5789d6e72f2ace2d4d4f747870567e197 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jun 2020 13:19:58 +0200 Subject: [PATCH] Implement Server-Side sessions. --- .../authentication/authentication_service.ts | 11 +- .../capture_url/capture_url_app.test.ts | 73 ++ .../capture_url/capture_url_app.ts | 68 ++ .../authentication/capture_url/index.ts | 7 + .../components/login_form/login_form.test.tsx | 17 +- .../components/login_form/login_form.tsx | 23 +- .../overwritten_session_page.test.tsx | 34 + .../overwritten_session_page.tsx | 3 +- x-pack/plugins/security/public/plugin.tsx | 1 + .../authentication_result.test.ts | 45 +- .../authentication/authentication_result.ts | 16 +- .../authentication/authenticator.test.ts | 677 +++++------------- .../server/authentication/authenticator.ts | 470 ++++++------ .../server/authentication/index.mock.ts | 1 - .../server/authentication/index.test.ts | 54 +- .../security/server/authentication/index.ts | 62 +- .../authentication/providers/oidc.test.ts | 197 ++--- .../server/authentication/providers/oidc.ts | 95 ++- .../authentication/providers/saml.test.ts | 507 +++---------- .../server/authentication/providers/saml.ts | 196 ++--- .../authorization_service.test.ts | 118 ++- .../authorization/authorization_service.ts | 74 +- x-pack/plugins/security/server/config.test.ts | 32 +- x-pack/plugins/security/server/config.ts | 4 +- .../elasticsearch_client_plugin.ts | 0 .../elasticsearch_service.test.ts | 107 +++ .../elasticsearch/elasticsearch_service.ts | 124 ++++ .../security/server/elasticsearch/index.ts | 12 + x-pack/plugins/security/server/index.ts | 14 + x-pack/plugins/security/server/plugin.test.ts | 40 +- x-pack/plugins/security/server/plugin.ts | 52 +- .../routes/authentication/basic.test.ts | 173 ----- .../server/routes/authentication/basic.ts | 46 -- .../routes/authentication/common.test.ts | 214 +++++- .../server/routes/authentication/common.ts | 56 +- .../server/routes/authentication/index.ts | 7 - .../server/routes/authentication/saml.ts | 74 +- .../security/server/routes/index.mock.ts | 2 + .../plugins/security/server/routes/index.ts | 8 +- .../extend.ts} | 21 +- .../server/routes/session_management/index.ts | 14 + .../server/routes/session_management/info.ts | 43 ++ .../routes/users/change_password.test.ts | 15 +- .../server/routes/users/change_password.ts | 6 +- .../routes/views/access_agreement.test.ts | 18 +- .../server/routes/views/access_agreement.ts | 10 +- .../server/routes/views/capture_url.ts | 28 + .../server/routes/views/index.test.ts | 4 + .../security/server/routes/views/index.ts | 2 + .../server/routes/views/logged_out.test.ts | 20 +- .../server/routes/views/logged_out.ts | 4 +- .../server/session_management/index.mock.ts | 7 + .../server/session_management/index.ts | 11 + .../server/session_management/session.mock.ts | 47 ++ .../server/session_management/session.test.ts | 358 +++++++++ .../server/session_management/session.ts | 450 ++++++++++++ .../session_management/session_cookie.ts | 133 ++++ .../session_management/session_index.ts | 378 ++++++++++ .../session_management_service.ts | 93 +++ .../security_solution/cypress/tasks/login.ts | 18 +- x-pack/scripts/functional_tests.js | 3 +- .../apis/security/basic_login.js | 39 +- .../apis/security/change_password.ts | 48 +- .../api_integration/apis/security/session.ts | 9 +- .../api_integration/services/legacy_es.js | 2 +- .../apis/security/kerberos_login.ts | 9 +- .../apis/login_selector.ts | 65 +- .../{index.js => index.ts} | 4 +- .../{oidc_auth.js => oidc_auth.ts} | 245 ++++--- .../apis/implicit_flow/oidc_auth.ts | 15 +- x-pack/test/oidc_api_integration/config.ts | 2 +- .../apis/security/pki_auth.ts | 10 +- .../apis/security/saml_login.ts | 373 ++++------ .../saml_provider/server/init_routes.ts | 11 + .../common/services/legacy_es.js | 2 +- .../ftr_provider_context.d.ts | 12 + .../login_selector.config.ts} | 8 +- .../test/security_functional/saml.config.ts | 78 ++ .../login_selector/basic_functionality.ts} | 8 +- .../tests/login_selector/index.ts | 15 + .../tests/saml}/index.ts | 6 +- .../tests/saml/url_capture.ts | 54 ++ .../common/services/legacy_es.js | 2 +- .../test/token_api_integration/auth/login.js | 48 +- .../test/token_api_integration/auth/logout.js | 9 +- .../token_api_integration/auth/session.js | 7 +- 86 files changed, 3943 insertions(+), 2535 deletions(-) create mode 100644 x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts create mode 100644 x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts create mode 100644 x-pack/plugins/security/public/authentication/capture_url/index.ts rename x-pack/plugins/security/server/{ => elasticsearch}/elasticsearch_client_plugin.ts (100%) create mode 100644 x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts create mode 100644 x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts create mode 100644 x-pack/plugins/security/server/elasticsearch/index.ts delete mode 100644 x-pack/plugins/security/server/routes/authentication/basic.test.ts delete mode 100644 x-pack/plugins/security/server/routes/authentication/basic.ts rename x-pack/plugins/security/server/routes/{authentication/session.ts => session_management/extend.ts} (57%) create mode 100644 x-pack/plugins/security/server/routes/session_management/index.ts create mode 100644 x-pack/plugins/security/server/routes/session_management/info.ts create mode 100644 x-pack/plugins/security/server/routes/views/capture_url.ts create mode 100644 x-pack/plugins/security/server/session_management/index.mock.ts create mode 100644 x-pack/plugins/security/server/session_management/index.ts create mode 100644 x-pack/plugins/security/server/session_management/session.mock.ts create mode 100644 x-pack/plugins/security/server/session_management/session.test.ts create mode 100644 x-pack/plugins/security/server/session_management/session.ts create mode 100644 x-pack/plugins/security/server/session_management/session_cookie.ts create mode 100644 x-pack/plugins/security/server/session_management/session_index.ts create mode 100644 x-pack/plugins/security/server/session_management/session_management_service.ts rename x-pack/test/oidc_api_integration/apis/authorization_code_flow/{index.js => index.ts} (73%) rename x-pack/test/oidc_api_integration/apis/authorization_code_flow/{oidc_auth.js => oidc_auth.ts} (73%) create mode 100644 x-pack/test/security_functional/ftr_provider_context.d.ts rename x-pack/test/{functional/config_security_trial.ts => security_functional/login_selector.config.ts} (93%) create mode 100644 x-pack/test/security_functional/saml.config.ts rename x-pack/test/{functional/apps/security/trial_license/login_selector.ts => security_functional/tests/login_selector/basic_functionality.ts} (94%) create mode 100644 x-pack/test/security_functional/tests/login_selector/index.ts rename x-pack/test/{functional/apps/security/trial_license => security_functional/tests/saml}/index.ts (65%) create mode 100644 x-pack/test/security_functional/tests/saml/url_capture.ts diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 6657f5c0a900cf9..c650763ed481a57 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public'; +import { + ApplicationSetup, + StartServicesAccessor, + HttpSetup, + FatalErrorsSetup, +} from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; import { PluginStartDependencies } from '../plugin'; @@ -13,9 +18,11 @@ import { loginApp } from './login'; import { logoutApp } from './logout'; import { loggedOutApp } from './logged_out'; import { overwrittenSessionApp } from './overwritten_session'; +import { captureURLApp } from './capture_url'; interface SetupParams { application: ApplicationSetup; + fatalErrors: FatalErrorsSetup; config: ConfigType; http: HttpSetup; getStartServices: StartServicesAccessor; @@ -36,6 +43,7 @@ export interface AuthenticationServiceSetup { export class AuthenticationService { public setup({ application, + fatalErrors, config, getStartServices, http, @@ -48,6 +56,7 @@ export class AuthenticationService { .apiKeysEnabled; accessAgreementApp.create({ application, getStartServices }); + captureURLApp.create({ application, fatalErrors, http }); loginApp.create({ application, config, getStartServices, http }); logoutApp.create({ application, http }); loggedOutApp.create({ application, getStartServices, http }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts new file mode 100644 index 000000000000000..c5b9245414630a7 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { AppMount, ScopedHistory } from 'src/core/public'; +import { captureURLApp } from './capture_url_app'; + +import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; + +describe('captureURLApp', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host' }, + writable: true, + }); + }); + + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + captureURLApp.create(coreSetupMock); + + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith( + '/internal/security/capture-url' + ); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'security_capture_url', + chromeless: true, + appRoute: '/internal/security/capture-url', + title: 'Capture URL', + mount: expect.any(Function), + }); + }); + + it('properly handles captured URL', async () => { + window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + '/mock-base-path/app/home' + )}&providerType=saml&providerName=saml1#/?_g=()`; + + const coreSetupMock = coreMock.createSetup(); + coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' }); + + captureURLApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ + element: document.createElement('div'), + appBasePath: '', + onAppLeave: jest.fn(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ + providerType: 'saml', + providerName: 'saml1', + currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + '/mock-base-path/app/home' + )}&providerType=saml&providerName=saml1#/?_g=()`, + }), + }); + + expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()'); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts new file mode 100644 index 000000000000000..81cce1499288cb8 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -0,0 +1,68 @@ +/* + * 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 { parse } from 'url'; +import { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; + +interface CreateDeps { + application: ApplicationSetup; + http: HttpSetup; + fatalErrors: FatalErrorsSetup; +} + +/** + * Some authentication providers need to know current user URL to, for example, restore it after a + * complex authentication handshake. But most of the Kibana URLs include hash fragment that is never + * sent to the server. To capture that authentication provider can redirect user to this app putting + * path segment into the `next` query string parameter (so that it's not lost during redirect). And + * since browsers preserve hash fragments during redirects (assuming redirect location doesn't + * specify its own hash fragment, which is true in our case) this app can capture both path and + * hash URL segments and send them back to the authentication provider via login endpoint. + * + * The flow can look like this: + * 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication. + * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`. + * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`. + * 4. The app captures full URL and sends it back as is via login endpoint: + * { + * providerType: 'saml', + * providerName: 'saml1', + * currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch' + * } + * 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment + * and finally passes it to the provider that initiated capturing. + */ +export const captureURLApp = Object.freeze({ + id: 'security_capture_url', + create({ application, fatalErrors, http }: CreateDeps) { + http.anonymousPaths.register('/internal/security/capture-url'); + application.register({ + id: this.id, + title: 'Capture URL', + chromeless: true, + appRoute: '/internal/security/capture-url', + async mount() { + try { + const { providerName, providerType } = parse(window.location.href, true).query ?? {}; + if (!providerName || !providerType) { + fatalErrors.add(new Error('Provider to capture URL for is not specified.')); + return () => {}; + } + + const { location } = await http.post<{ location: string }>('/internal/security/login', { + body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }), + }); + + window.location.href = location; + } catch (err) { + fatalErrors.add(new Error('Cannot login with captured URL.')); + } + + return () => {}; + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/capture_url/index.ts b/x-pack/plugins/security/public/authentication/capture_url/index.ts new file mode 100644 index 000000000000000..6dc1c2f7e2c27f5 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { captureURLApp } from './capture_url_app'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index 39131f9f4499f22..552d523fa4a84bb 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -171,7 +171,7 @@ describe('LoginForm', () => { '/some-base-path/app/home#/?_g=()' )}`; const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); - coreStartMock.http.post.mockResolvedValue({}); + coreStartMock.http.post.mockResolvedValue({ location: '/some-base-path/app/home#/?_g=()' }); const wrapper = mountWithIntl( { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }], }} /> ); @@ -198,7 +198,14 @@ describe('LoginForm', () => { expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ username: 'username1', password: 'password1' }), + body: JSON.stringify({ + providerType: 'basic', + providerName: 'basic1', + currentURL: `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/home#/?_g=()' + )}`, + params: { username: 'username1', password: 'password1' }, + }), }); expect(window.location.href).toBe('/some-base-path/app/home#/?_g=()'); @@ -363,7 +370,7 @@ describe('LoginForm', () => { }); expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); - expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), }); @@ -407,7 +414,7 @@ describe('LoginForm', () => { }); expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); - expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index ec631e8a2b52568..9ea553af75e0007 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -29,7 +29,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; -import { parseNext } from '../../../../../common/parse_next'; import { LoginSelector } from '../../../../../common/login_state'; import { LoginValidator } from './validate_login'; @@ -401,11 +400,25 @@ export class LoginForm extends Component { message: { type: MessageType.None }, }); - const { http } = this.props; + // We try to log in with the provider that uses login form and has the lowest order. + const providerToLoginWith = this.props.selector.providers.find( + (provider) => provider.usesLoginForm + )!; try { - await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); - window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + const { location } = await this.props.http.post<{ location: string }>( + '/internal/security/login', + { + body: JSON.stringify({ + providerType: providerToLoginWith.type, + providerName: providerToLoginWith.name, + currentURL: window.location.href, + params: { username, password }, + }), + } + ); + + window.location.href = location; } catch (error) { const message = (error as IHttpFetchError).response?.status === 401 @@ -432,7 +445,7 @@ export class LoginForm extends Component { try { const { location } = await this.props.http.post<{ location: string }>( - '/internal/security/login_with', + '/internal/security/login', { body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) } ); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx index 7422319951a8a0a..1fc8824eeff3adc 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { EuiButton } from '@elastic/eui'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { OverwrittenSessionPage } from './overwritten_session_page'; @@ -15,6 +16,13 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user. import { AuthenticationStatePage } from '../components/authentication_state_page'; describe('OverwrittenSessionPage', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host' }, + writable: true, + }); + }); + it('renders as expected', async () => { const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath; const authenticationSetupMock = authenticationMock.createSetup(); @@ -36,4 +44,30 @@ describe('OverwrittenSessionPage', () => { expect(wrapper.find(AuthenticationStatePage)).toMatchSnapshot(); }); + + it('properly parses `next` parameter', async () => { + window.location.href = `https://host.com/mock-base-path/security/overwritten_session?next=${encodeURIComponent( + '/mock-base-path/app/home#/?_g=()' + )}`; + + const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath; + const authenticationSetupMock = authenticationMock.createSetup(); + authenticationSetupMock.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ username: 'mock-user' }) + ); + + const wrapper = mountWithIntl( + + ); + + // Shouldn't render anything if username isn't yet available. + expect(wrapper.isEmptyRender()).toBe(true); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/app/home#/?_g=()'); + }); }); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx index 455cc9fb9ce1fee..ee8784cdd0f9f8e 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx @@ -9,6 +9,7 @@ import ReactDOM from 'react-dom'; import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, IBasePath } from 'src/core/public'; +import { parseNext } from '../../../common/parse_next'; import { AuthenticationServiceSetup } from '../authentication_service'; import { AuthenticationStatePage } from '../components'; @@ -36,7 +37,7 @@ export function OverwrittenSessionPage({ authc, basePath }: Props) { /> } > - + { ); }); - it('correctly produces `redirected` authentication result without state.', () => { + it('correctly produces `redirected` authentication result without state, user and response headers.', () => { const redirectURL = '/redirect/url'; const authenticationResult = AuthenticationResult.redirectTo(redirectURL); @@ -201,6 +201,49 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); + + it('correctly produces `redirected` authentication result with state and user.', () => { + const redirectURL = '/redirect/url'; + const state = { some: 'state' }; + const user = mockAuthenticatedUser(); + const authenticationResult = AuthenticationResult.redirectTo(redirectURL, { user, state }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.succeeded()).toBe(false); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + + expect(authenticationResult.redirectURL).toBe(redirectURL); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.error).toBeUndefined(); + }); + + it('correctly produces `redirected` authentication result with state, user and response headers.', () => { + const redirectURL = '/redirect/url'; + const state = { some: 'state' }; + const user = mockAuthenticatedUser(); + const authResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; + const authenticationResult = AuthenticationResult.redirectTo(redirectURL, { + user, + state, + authResponseHeaders, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.succeeded()).toBe(false); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + + expect(authenticationResult.redirectURL).toBe(redirectURL); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.authResponseHeaders).toBe(authResponseHeaders); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.error).toBeUndefined(); + }); }); describe('shouldUpdateState', () => { diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index 826665a3b8a305d..a5e744ba369152c 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -113,17 +113,29 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication needs user to be redirected. * @param redirectURL URL that should be used to redirect user to complete authentication. + * @param [user] Optional user information retrieved as a result of successful authentication attempt. + * @param [authResponseHeaders] Optional dictionary of the HTTP headers with authentication + * information that should be specified in the response we send to the client request. * @param [state] Optional state to be stored and reused for the next request. */ public static redirectTo( redirectURL: string, - { state }: Pick = {} + { + user, + authResponseHeaders, + state, + }: Pick = {} ) { if (!redirectURL) { throw new Error('Redirect URL must be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Redirected, { redirectURL, state }); + return new AuthenticationResult(AuthenticationResultStatus.Redirected, { + redirectURL, + user, + authResponseHeaders, + state, + }); } /** diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 300447096af9974..abd02aff540e1ac 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -10,34 +10,31 @@ jest.mock('./providers/saml'); jest.mock('./providers/http'); import Boom from 'boom'; -import { duration, Duration } from 'moment'; -import { SessionStorage } from '../../../../../src/core/server'; import { loggingSystemMock, httpServiceMock, httpServerMock, elasticsearchServiceMock, - sessionStorageMock, } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { securityAuditLoggerMock } from '../audit/index.mock'; +import { sessionMock } from '../session_management/index.mock'; import { SecurityLicenseFeatures } from '../../common/licensing'; import { ConfigSchema, createConfig } from '../config'; +import { SessionValue } from '../session_management'; import { AuthenticationResult } from './authentication_result'; -import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; +import { Authenticator, AuthenticatorOptions } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; function getMockOptions({ - session, providers, http = {}, selector, }: { - session?: AuthenticatorOptions['config']['session']; providers?: Record | string[]; http?: Partial; selector?: AuthenticatorOptions['config']['authc']['selector']; @@ -50,11 +47,11 @@ function getMockOptions({ license: licenseMock.create(), loggers: loggingSystemMock.create(), config: createConfig( - ConfigSchema.validate({ session, authc: { selector, providers, http } }), + ConfigSchema.validate({ authc: { selector, providers, http } }), loggingSystemMock.create().get(), { isTLSEnabled: false } ), - sessionStorageFactory: sessionStorageMock.createFactory(), + session: sessionMock.create(), getFeatureUsageService: jest .fn() .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), @@ -88,6 +85,54 @@ describe('Authenticator', () => { })); }); + /* it(`redirects to \`overwritten_session\` if new SAML Response is for the another user if ${description}.`, async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const state = { + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; + + mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'new-user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + }); + + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + await expect( + provider.login( + request, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + state + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/mock-server-basepath/security/overwritten_session', { + state: { + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', + }, + user: mockUser, + }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlAuthenticate', { + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, + }); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }); + });*/ + afterEach(() => jest.clearAllMocks()); describe('initialization', () => { @@ -216,20 +261,14 @@ describe('Authenticator', () => { describe('`login` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, + mockOptions.session.get.mockResolvedValue(null); + mockSessVal = sessionMock.createSessionValue({ state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, - }; + }); authenticator = new Authenticator(mockOptions); }); @@ -304,9 +343,10 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'basic' }, value: {} }) ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: { authorization } })); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -361,9 +401,7 @@ describe('Authenticator', () => { }, }, }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + mockOptions.session.get.mockResolvedValue(null); authenticator = new Authenticator(mockOptions); }); @@ -382,9 +420,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, provider: { type: 'saml', name: 'saml2' }, state: { token: 'access-token' }, }); @@ -400,7 +438,7 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'saml' }, value: {} }) ).resolves.toEqual(AuthenticationResult.notHandled()); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); @@ -412,10 +450,11 @@ describe('Authenticator', () => { it('returns as soon as provider handles request', async () => { const request = httpServerMock.createKibanaRequest(); + const user = mockAuthenticatedUser(); const authenticationResults = [ AuthenticationResult.failed(new Error('Fail')), - AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }), + AuthenticationResult.succeeded(user, { state: { result: '200' } }), AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }), ]; @@ -427,14 +466,14 @@ describe('Authenticator', () => { ).resolves.toEqual(result); } - expect(mockSessionStorage.set).toHaveBeenCalledTimes(2); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(2); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, provider: { type: 'saml', name: 'saml1' }, state: { result: '200' }, }); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: undefined, provider: { type: 'saml', name: 'saml1' }, state: { result: '302' }, }); @@ -447,7 +486,7 @@ describe('Authenticator', () => { it('provides session only if provider name matches', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue({ + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, provider: { type: 'saml', name: 'saml2' }, }); @@ -480,64 +519,10 @@ describe('Authenticator', () => { }); }); - it('clears session if it belongs to a different provider.', async () => { - const user = mockAuthenticatedUser(); - const credentials = { username: 'user', password: 'password' }; - const request = httpServerMock.createKibanaRequest(); - - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); - - await expect( - authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); - - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( - request, - credentials, - null - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { - const user = mockAuthenticatedUser(); - const credentials = { username: 'user', password: 'password' }; - const request = httpServerMock.createKibanaRequest(); - - // Re-configure authenticator with `token` provider that uses the name of `basic`. - const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user)); - jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({ - type: 'token', - login: loginMock, - getHTTPAuthenticationScheme: jest.fn(), - })); - mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - - await expect( - authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); - - expect(loginMock).toHaveBeenCalledWith(request, credentials, null); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - it('clears session if provider asked to do so.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) @@ -547,48 +532,25 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'basic' }, value: {} }) ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: null })); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); - - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - - await expect( - authenticator.login(request, { provider: { type: 'basic' }, value: {} }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); - - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); }); }); describe('`authenticate` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, + mockOptions.session.get.mockResolvedValue(null); + mockSessVal = sessionMock.createSessionValue({ state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, - }; + }); authenticator = new Authenticator(mockOptions); }); @@ -642,9 +604,10 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -664,9 +627,10 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -680,14 +644,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('extends session for non-system API calls.', async () => { @@ -699,161 +665,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith(mockSessVal); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('properly extends session expiration if it is defined.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - - // Create new authenticator with non-null session `idleTimeout`. - mockOptions = getMockOptions({ - session: { - idleTimeout: duration(3600 * 24), - lifespan: null, - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: currentDate + 3600 * 24, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('does not extend session lifespan expiration.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - const hr = 1000 * 60 * 60; - - // Create new authenticator with non-null session `idleTimeout` and `lifespan`. - mockOptions = getMockOptions({ - session: { - idleTimeout: duration(hr * 2), - lifespan: duration(hr * 8), - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - // this session was created 6.5 hrs ago (and has 1.5 hrs left in its lifespan) - // it was last extended 1 hour ago, which means it will expire in 1 hour - idleTimeoutExpiration: currentDate + hr * 1, - lifespanExpiration: currentDate + hr * 1.5, - }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: currentDate + hr * 2, - lifespanExpiration: currentDate + hr * 1.5, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - describe('conditionally updates the session lifespan expiration', () => { - const hr = 1000 * 60 * 60; - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - - async function createAndUpdateSession( - lifespan: Duration | null, - oldExpiration: number | null, - newExpiration: number | null - ) { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - mockOptions = getMockOptions({ - session: { - idleTimeout: null, - lifespan, - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: oldExpiration, - }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: newExpiration, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - } - - it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { - await createAndUpdateSession(duration(hr * 8), 1234, 1234); - }); - it('does not change a null lifespan expiration when configured to null value.', async () => { - await createAndUpdateSession(null, null, null); - }); - it('does change a non-null lifespan expiration when configured to null value.', async () => { - await createAndUpdateSession(null, 1234, null); - }); - it('does change a null lifespan expiration when configured to non-null value', async () => { - await createAndUpdateSession(duration(hr * 8), null, currentDate + hr * 8); - }); + expect(mockOptions.session.extend).toHaveBeenCalledTimes(1); + expect(mockOptions.session.extend).toHaveBeenCalledWith(request, mockSessVal); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => { @@ -865,14 +687,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(failureReason) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => { @@ -884,14 +708,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(failureReason) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('replaces existing session with the one returned by authentication provider for system API requests', async () => { @@ -904,18 +730,20 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user, { state: newState }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user, { state: newState }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessVal, state: newState, }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => { @@ -928,18 +756,20 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user, { state: newState }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user, { state: newState }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessVal, state: newState, }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { @@ -950,14 +780,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('clears session if provider failed to authenticate non-system API request with 401 with active session.', async () => { @@ -968,14 +801,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('clears session if provider requested it via setting state to `null`.', async () => { @@ -984,36 +820,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('some-url', { state: null }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo('some-url', { state: null }) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('does not clear session if provider can not handle system API request authentication with active session.', async () => { @@ -1021,14 +838,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('does not clear session if provider can not handle non-system API request authentication with active session.', async () => { @@ -1036,50 +855,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('clears session for system API request if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { 'kbn-system-request': 'true' }, - }); - - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears session for non-system API request if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { 'kbn-system-request': 'false' }, - }); - - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); describe('with Login Selector', () => { @@ -1088,14 +873,13 @@ describe('Authenticator', () => { selector: { enabled: true }, providers: { basic: { basic1: { order: 0 } } }, }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); }); it('does not redirect to Login Selector if there is an active session', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -1125,7 +909,6 @@ describe('Authenticator', () => { it('does not redirect to Login Selector if it is not enabled', async () => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); const request = httpServerMock.createKibanaRequest(); @@ -1154,7 +937,6 @@ describe('Authenticator', () => { basic: { basic1: { order: 0, accessAgreement: { message: 'some notice' } } }, }, }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: true, } as SecurityLicenseFeatures); @@ -1168,7 +950,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if there is no active session', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1177,7 +959,7 @@ describe('Authenticator', () => { it('does not redirect AJAX requests to Access Agreement', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1186,7 +968,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if request cannot be handled', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() @@ -1199,7 +981,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if authentication fails', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); const failureReason = new Error('something went wrong'); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -1213,7 +995,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if redirect is required to complete authentication', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('/some-url') @@ -1226,7 +1008,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if user has already acknowledged it', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue({ + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, accessAgreementAcknowledged: true, }); @@ -1238,7 +1020,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement its own requests', async () => { const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1247,8 +1029,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if it is not configured', async () => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); authenticator = new Authenticator(mockOptions); const request = httpServerMock.createKibanaRequest(); @@ -1259,7 +1040,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if license doesnt allow it.', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: false, } as SecurityLicenseFeatures); @@ -1270,12 +1051,20 @@ describe('Authenticator', () => { }); it('redirects to Access Agreement when needed.', async () => { - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); + mockOptions.session.extend.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); const request = httpServerMock.createKibanaRequest(); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath' + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { user: mockUser, authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' } } ) ); }); @@ -1285,19 +1074,13 @@ describe('Authenticator', () => { describe('`logout` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, + mockSessVal = sessionMock.createSessionValue({ state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, - }; + }); authenticator = new Authenticator(mockOptions); }); @@ -1310,14 +1093,14 @@ describe('Authenticator', () => { it('returns `notHandled` if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('clears session and returns whatever authentication provider returns.', async () => { @@ -1325,19 +1108,19 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo('some-url') ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalled(); }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } }); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') @@ -1348,81 +1131,18 @@ describe('Authenticator', () => { ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); 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); - - await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() - ); - - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('clears session if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest(); - const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - state, - provider: { type: 'token', name: 'token1' }, - }); + mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - }); - - describe('`getSessionInfo` method', () => { - let authenticator: Authenticator; - let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - beforeEach(() => { - mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - }); - - it('returns current session info if session exists.', async () => { - const request = httpServerMock.createKibanaRequest(); - const state = { authorization: 'Basic xxx' }; - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - const mockInfo = { - now: currentDate, - idleTimeoutExpiration: currentDate + 60000, - lifespanExpiration: currentDate + 120000, - provider: { type: 'basic' as 'basic', name: 'basic1' }, - }; - mockSessionStorage.get.mockResolvedValue({ - idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, - lifespanExpiration: mockInfo.lifespanExpiration, - state, - provider: mockInfo.provider, - path: mockOptions.basePath.serverBasePath, - }); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - const sessionInfo = await authenticator.getSessionInfo(request); - - expect(sessionInfo).toEqual(mockInfo); - }); - - it('returns `null` if session does not exist.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); - - const sessionInfo = await authenticator.getSessionInfo(request); - - expect(sessionInfo).toBe(null); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); }); @@ -1450,20 +1170,14 @@ describe('Authenticator', () => { describe('`acknowledgeAccessAgreement` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessionValue: any; + let mockSessionValue: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessionValue = { - idleTimeoutExpiration: null, - lifespanExpiration: null, + mockSessionValue = sessionMock.createSessionValue({ state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, - }; - mockSessionStorage.get.mockResolvedValue(mockSessionValue); + }); + mockOptions.session.get.mockResolvedValue(mockSessionValue); mockOptions.getCurrentUser.mockReturnValue(mockAuthenticatedUser()); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: true, @@ -1481,14 +1195,14 @@ describe('Authenticator', () => { `"Cannot acknowledge access agreement for unauthenticated user."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); }); it('fails if cannot retrieve user session', async () => { - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); await expect( authenticator.acknowledgeAccessAgreement(httpServerMock.createKibanaRequest()) @@ -1496,7 +1210,7 @@ describe('Authenticator', () => { `"Cannot acknowledge access agreement for unauthenticated user."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); @@ -1513,17 +1227,18 @@ describe('Authenticator', () => { `"Current license does not allow access agreement acknowledgement."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); }); it('properly acknowledges access agreement for the authenticated user', async () => { - await authenticator.acknowledgeAccessAgreement(httpServerMock.createKibanaRequest()); + const request = httpServerMock.createKibanaRequest(); + await authenticator.acknowledgeAccessAgreement(request); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessionValue, accessAgreementAcknowledged: true, }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 42ec3f79bddf369..a6cbc218985dca7 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -4,22 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Duration } from 'moment'; import { - SessionStorageFactory, - SessionStorage, KibanaRequest, LoggerFactory, - Logger, - HttpServiceSetup, ILegacyClusterClient, + IBasePath, } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; -import { AuthenticationProvider, SessionInfo } from '../../common/types'; +import { AuthenticationProvider } from '../../common/types'; import { SecurityAuditLogger } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { SessionValue, Session } from '../session_management'; import { AuthenticationProviderOptions, @@ -38,45 +36,6 @@ import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { canRedirectRequest } from './can_redirect_request'; import { HTTPAuthorizationHeader } from './http_authentication'; -import { SecurityFeatureUsageServiceStart } from '../feature_usage'; - -/** - * The shape of the session that is actually stored in the cookie. - */ -export interface ProviderSession { - /** - * Name and type of the provider this session belongs to. - */ - provider: AuthenticationProvider; - - /** - * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. - */ - idleTimeoutExpiration: number | null; - - /** - * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire - * time can be extended indefinitely. - */ - lifespanExpiration: number | null; - - /** - * Session value that is fed to the authentication provider. The shape is unknown upfront and - * entirely determined by the authentication provider that owns the current session. - */ - state: unknown; - - /** - * Cookie "Path" attribute that is validated against the current Kibana server configuration. - */ - path: string; - - /** - * Indicates whether user acknowledged access agreement or not. - */ - accessAgreementAcknowledged?: boolean; -} /** * The shape of the login attempt. @@ -87,6 +46,12 @@ export interface ProviderLoginAttempt { */ provider: Pick | Pick; + /** + * Optional URL to redirect user to after successful login. This URL is ignored if provider + * decides to redirect user to another URL after login. + */ + redirectURL?: string; + /** * Login attempt can have any form and defined by the specific provider. */ @@ -97,12 +62,12 @@ export interface AuthenticatorOptions { auditLogger: SecurityAuditLogger; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; - config: Pick; - basePath: HttpServiceSetup['basePath']; + config: Pick; + basePath: IBasePath; license: SecurityLicense; loggers: LoggerFactory; clusterClient: ILegacyClusterClient; - sessionStorageFactory: SessionStorageFactory; + session: PublicMethodsOf; } // Mapping between provider key defined in the config and authentication @@ -127,6 +92,11 @@ const providerMap = new Map< */ const ACCESS_AGREEMENT_ROUTE = '/security/access_agreement'; +/** + * The route to the overwritten session UI. + */ +const OVERWRITTEN_SESSION_ROUTE = '/security/overwritten_session'; + function assertRequest(request: KibanaRequest) { if (!(request instanceof KibanaRequest)) { throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); @@ -161,15 +131,6 @@ function isLoginAttemptWithProviderType( ); } -/** - * Determines if session value was created by the previous Kibana versions which had a different - * session value format. - * @param sessionValue The session value to check. - */ -function isLegacyProviderSession(sessionValue: any) { - return typeof sessionValue?.provider === 'string'; -} - /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -209,32 +170,20 @@ export class Authenticator { private readonly providers: Map; /** - * Which base path the HTTP server is hosted on. - */ - private readonly serverBasePath: string; - - /** - * Session timeout in ms. If `null` session will stay active until the browser is closed. + * Session instance. */ - private readonly idleTimeout: Duration | null = null; - - /** - * Session max lifespan in ms. If `null` session may live indefinitely. - */ - private readonly lifespan: Duration | null = null; + private readonly session = this.options.session; /** * Internal authenticator logger. */ - private readonly logger: Logger; + private readonly logger = this.options.loggers.get('authenticator'); /** * Instantiates Authenticator and bootstrap configured providers. * @param options Authenticator options. */ constructor(private readonly options: Readonly) { - this.logger = options.loggers.get('authenticator'); - const providerCommonOptions = { client: this.options.clusterClient, basePath: this.options.basePath, @@ -284,11 +233,6 @@ export class Authenticator { 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' ); } - - this.serverBasePath = this.options.basePath.serverBasePath || '/'; - - this.idleTimeout = this.options.config.session.idleTimeout; - this.lifespan = this.options.config.session.lifespan; } /** @@ -300,8 +244,7 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.session.get(request); // Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI) // or a group of providers with the specified type (e.g. in case of 3rd-party initiated login @@ -311,7 +254,7 @@ export class Authenticator { isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name) ? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]] : isLoginAttemptWithProviderType(attempt) - ? [...this.providerIterator(existingSession)].filter( + ? [...this.providerIterator(existingSessionValue)].filter( ([, { type }]) => type === attempt.provider.type ) : []; @@ -330,24 +273,28 @@ export class Authenticator { for (const [providerName, provider] of providers) { // Check if current session has been set by this provider. const ownsSession = - existingSession?.provider.name === providerName && - existingSession?.provider.type === provider.type; + existingSessionValue?.provider.name === providerName && + existingSessionValue?.provider.type === provider.type; const authenticationResult = await provider.login( request, attempt.value, - ownsSession ? existingSession!.state : null + ownsSession ? existingSessionValue!.state : null ); - this.updateSessionValue(sessionStorage, { - provider: { type: provider.type, name: providerName }, - isSystemRequest: request.isSystemRequest, - authenticationResult, - existingSession: ownsSession ? existingSession : null, - }); - if (!authenticationResult.notHandled()) { - return authenticationResult; + const sessionUpdateResult = await this.updateSessionValue(request, { + provider: { type: provider.type, name: providerName }, + authenticationResult, + existingSessionValue, + }); + + return this.handlePreAccessRedirects( + request, + authenticationResult, + sessionUpdateResult, + attempt.redirectURL + ); } } @@ -361,10 +308,9 @@ export class Authenticator { async authenticate(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.session.get(request); - if (this.shouldRedirectToLoginSelector(request, existingSession)) { + if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) { this.logger.debug('Redirecting request to Login Selector.'); return AuthenticationResult.redirectTo( `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( @@ -373,40 +319,27 @@ export class Authenticator { ); } - for (const [providerName, provider] of this.providerIterator(existingSession)) { + for (const [providerName, provider] of this.providerIterator(existingSessionValue)) { // Check if current session has been set by this provider. const ownsSession = - existingSession?.provider.name === providerName && - existingSession?.provider.type === provider.type; + existingSessionValue?.provider.name === providerName && + existingSessionValue?.provider.type === provider.type; const authenticationResult = await provider.authenticate( request, - ownsSession ? existingSession!.state : null + ownsSession ? existingSessionValue!.state : null ); - const updatedSession = this.updateSessionValue(sessionStorage, { - provider: { type: provider.type, name: providerName }, - isSystemRequest: request.isSystemRequest, - authenticationResult, - existingSession: ownsSession ? existingSession : null, - }); - if (!authenticationResult.notHandled()) { - if ( - authenticationResult.succeeded() && - this.shouldRedirectToAccessAgreement(request, updatedSession) - ) { - this.logger.debug('Redirecting user to the access agreement screen.'); - return AuthenticationResult.redirectTo( - `${ - this.options.basePath.serverBasePath - }${ACCESS_AGREEMENT_ROUTE}?next=${encodeURIComponent( - `${this.options.basePath.get(request)}${request.url.path}` - )}` - ); - } - - return authenticationResult; + const sessionUpdateResult = await this.updateSessionValue(request, { + provider: { type: provider.type, name: providerName }, + authenticationResult, + existingSessionValue, + }); + + return canRedirectRequest(request) + ? this.handlePreAccessRedirects(request, authenticationResult, sessionUpdateResult) + : authenticationResult; } } @@ -420,19 +353,17 @@ export class Authenticator { async logout(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const sessionValue = await this.getSessionValue(sessionStorage); + const sessionValue = await this.session.get(request); if (sessionValue) { - sessionStorage.clear(); - + await this.session.clear(request); return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); } - const providerName = this.getProviderName(request.query); - if (providerName) { + const queryStringProviderName = (request.query as Record)?.provider; + if (typeof queryStringProviderName === 'string') { // provider name is passed in a query param and sourced from the browser's local storage; // hence, we can't assume that this provider exists, so we have to check it - const provider = this.providers.get(providerName); + const provider = this.providers.get(queryStringProviderName); if (provider) { return provider.logout(request, null); } @@ -454,29 +385,6 @@ export class Authenticator { return DeauthenticationResult.notHandled(); } - /** - * Returns session information for the current request. - * @param request Request instance. - */ - async getSessionInfo(request: KibanaRequest): Promise { - assertRequest(request); - - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const sessionValue = await this.getSessionValue(sessionStorage); - - if (sessionValue) { - // We can't rely on the client's system clock, so in addition to returning expiration timestamps, we also return - // the current server time -- that way the client can calculate the relative time to expiration. - return { - now: Date.now(), - idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, - lifespanExpiration: sessionValue.lifespanExpiration, - provider: sessionValue.provider, - }; - } - return null; - } - /** * Checks whether specified provider type is currently enabled. * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). @@ -492,10 +400,9 @@ export class Authenticator { async acknowledgeAccessAgreement(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.session.get(request); const currentUser = this.options.getCurrentUser(request); - if (!existingSession || !currentUser) { + if (!existingSessionValue || !currentUser) { throw new Error('Cannot acknowledge access agreement for unauthenticated user.'); } @@ -503,11 +410,14 @@ export class Authenticator { throw new Error('Current license does not allow access agreement acknowledgement.'); } - sessionStorage.set({ ...existingSession, accessAgreementAcknowledged: true }); + await this.session.update(request, { + ...existingSessionValue, + accessAgreementAcknowledged: true, + }); this.options.auditLogger.accessAgreementAcknowledged( currentUser.username, - existingSession.provider + existingSessionValue.provider ); this.options.getFeatureUsageService().recordPreAccessAgreementUsage(); @@ -546,7 +456,7 @@ export class Authenticator { * @param sessionValue Current session value. */ private *providerIterator( - sessionValue: ProviderSession | null + sessionValue: SessionValue | null ): IterableIterator<[string, BaseAuthenticationProvider]> { // If there is no session to predict which provider to use first, let's use the order // providers are configured in. Otherwise return provider that owns session first, and only then the rest @@ -564,45 +474,42 @@ export class Authenticator { } } - /** - * Extracts session value for the specified request. Under the hood it can - * clear session if it belongs to the provider that is not available. - * @param sessionStorage Session storage instance. - */ - private async getSessionValue(sessionStorage: SessionStorage) { - const sessionValue = await sessionStorage.get(); - - // If we detect that session is in incompatible format or for some reason we have a session - // stored for the provider that is not available anymore (e.g. when user was logged in with one - // provider, but then configuration has changed and that provider is no longer available), then - // we should clear session entirely. - if ( - sessionValue && - (isLegacyProviderSession(sessionValue) || - this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) - ) { - sessionStorage.clear(); - return null; - } - - return sessionValue; - } - - private updateSessionValue( - sessionStorage: SessionStorage, + private async updateSessionValue( + request: KibanaRequest, { provider, authenticationResult, - existingSession, - isSystemRequest, + existingSessionValue, }: { provider: AuthenticationProvider; authenticationResult: AuthenticationResult; - existingSession: ProviderSession | null; - isSystemRequest: boolean; + existingSessionValue: Readonly | null; } ) { - if (!existingSession && !authenticationResult.shouldUpdateState()) { + if (!existingSessionValue && !authenticationResult.shouldUpdateState()) { + return null; + } + + // Provider can specifically ask to clear session by setting it to `null` even if authentication + // attempt didn't fail. + if (authenticationResult.shouldClearState()) { + this.logger.debug('Authentication provider requested to invalidate existing session.'); + await this.session.clear(request); + return null; + } + + const ownsSession = + existingSessionValue?.provider.name === provider.name && + existingSessionValue?.provider.type === provider.type; + + // If provider owned the session, but failed to authenticate anyway, that likely means that + // session is not valid and we should clear it. Unexpected errors should not cause session + // invalidation (e.g. when Elasticsearch is temporarily unavailable). + if (authenticationResult.failed()) { + if (ownsSession && getErrorStatusCode(authenticationResult.error) === 401) { + this.logger.debug('Authentication attempt failed, existing session will be invalidated.'); + await this.session.clear(request); + } return null; } @@ -611,68 +518,83 @@ export class Authenticator { // state we should store it in the session regardless of whether it's a system API request or not. const sessionCanBeUpdated = (authenticationResult.succeeded() || authenticationResult.redirected()) && - (authenticationResult.shouldUpdateState() || !isSystemRequest); + (authenticationResult.shouldUpdateState() || (!request.isSystemRequest && ownsSession)); + if (!sessionCanBeUpdated) { + return ownsSession ? { value: existingSessionValue, overwritten: false } : null; + } - // If provider owned the session, but failed to authenticate anyway, that likely means that - // session is not valid and we should clear it. Also provider can specifically ask to clear - // session by setting it to `null` even if authentication attempt didn't fail. - if ( - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) - ) { - sessionStorage.clear(); - return null; + const isExistingSessionAuthenticated = !!existingSessionValue?.username; + const isNewSessionAuthenticated = !!authenticationResult.user; + + const providerHasChanged = !!existingSessionValue && !ownsSession; + const sessionHasBeenAuthenticated = + !isExistingSessionAuthenticated && isNewSessionAuthenticated; + const usernameHasChanged = + isExistingSessionAuthenticated && + isNewSessionAuthenticated && + authenticationResult.user!.username !== existingSessionValue!.username; + + // There are 3 cases when we SHOULD invalidate existing session and create a new one with + // regenerated SID/AAD: + // 1. If a new session must be created while existing is still valid (e.g. IdP initiated login + // for the user with active session created by another provider). + // 2. If the existing session was unauthenticated (e.g. intermediate session used during SSO + // handshake) and can now be turned into an authenticated one. + // 3. If we re-authenticated user with another username (e.g. during IdP initiated SSO login or + // when client certificate changes and PKI provider needs to re-authenticate user). + if (providerHasChanged) { + this.logger.debug( + 'Authentication provider has changed, existing session will be invalidated.' + ); + await this.session.clear(request); + existingSessionValue = null; + } else if (sessionHasBeenAuthenticated) { + this.logger.debug( + 'Session is authenticated, existing unauthenticated session will be invalidated.' + ); + await this.session.clear(request); + existingSessionValue = null; + } else if (usernameHasChanged) { + this.logger.debug('Username has changed, existing session will be invalidated.'); + await this.session.clear(request); + existingSessionValue = null; } - if (sessionCanBeUpdated) { - const { idleTimeoutExpiration, lifespanExpiration } = this.calculateExpiry(existingSession); - const updatedSession = { + let newSessionValue; + if (!existingSessionValue) { + newSessionValue = await this.session.create(request, { + username: authenticationResult.user?.username, + provider, + state: authenticationResult.shouldUpdateState() ? authenticationResult.state : null, + }); + } else if (authenticationResult.shouldUpdateState()) { + newSessionValue = await this.session.update(request, { + ...existingSessionValue, state: authenticationResult.shouldUpdateState() ? authenticationResult.state - : existingSession!.state, - provider, - idleTimeoutExpiration, - lifespanExpiration, - path: this.serverBasePath, - accessAgreementAcknowledged: existingSession?.accessAgreementAcknowledged, - }; - sessionStorage.set(updatedSession); - return updatedSession; + : existingSessionValue.state, + }); + } else { + newSessionValue = await this.session.extend(request, existingSessionValue); } - return existingSession; - } - - private getProviderName(query: any): string | null { - if (query && query.provider && typeof query.provider === 'string') { - return query.provider; - } - return null; - } - - private calculateExpiry( - existingSession: ProviderSession | null - ): { idleTimeoutExpiration: number | null; lifespanExpiration: number | null } { - const now = Date.now(); - // if we are renewing an existing session, use its `lifespanExpiration` -- otherwise, set this value - // based on the configured server `lifespan`. - // note, if the server had a `lifespan` set and then removes it, remove `lifespanExpiration` on renewed sessions - // also, if the server did not have a `lifespan` set and then adds it, add `lifespanExpiration` on renewed sessions - const lifespanExpiration = - existingSession?.lifespanExpiration && this.lifespan - ? existingSession.lifespanExpiration - : this.lifespan && now + this.lifespan.asMilliseconds(); - const idleTimeoutExpiration = this.idleTimeout && now + this.idleTimeout.asMilliseconds(); - - return { idleTimeoutExpiration, lifespanExpiration }; + return { + value: newSessionValue, + // We care only about cases when one authenticated session has been overwritten by another + // authenticated session that belongs to a different user (different name or provider/realm). + overwritten: + isExistingSessionAuthenticated && + isNewSessionAuthenticated && + (providerHasChanged || usernameHasChanged), + }; } /** * Checks whether request should be redirected to the Login Selector UI. * @param request Request instance. - * @param session Current session value if any. + * @param sessionValue Current session value if any. */ - private shouldRedirectToLoginSelector(request: KibanaRequest, session: ProviderSession | null) { + private shouldRedirectToLoginSelector(request: KibanaRequest, sessionValue: SessionValue | null) { // Request should be redirected to Login Selector UI only if all following conditions are met: // 1. Request can be redirected (not API call) // 2. Request is not authenticated yet @@ -680,7 +602,7 @@ export class Authenticator { // 4. Request isn't attributed with HTTP Authorization header return ( canRedirectRequest(request) && - !session && + !sessionValue && this.options.config.authc.selector.enabled && HTTPAuthorizationHeader.parseFromRequest(request) == null ); @@ -688,10 +610,9 @@ export class Authenticator { /** * Checks whether request should be redirected to the Access Agreement UI. - * @param request Request instance. - * @param session Current session value if any. + * @param sessionValue Current session value if any. */ - private shouldRedirectToAccessAgreement(request: KibanaRequest, session: ProviderSession | null) { + private shouldRedirectToAccessAgreement(sessionValue: SessionValue | null) { // Request should be redirected to Access Agreement UI only if all following conditions are met: // 1. Request can be redirected (not API call) // 2. Request is authenticated, but user hasn't acknowledged access agreement in the current @@ -700,14 +621,69 @@ export class Authenticator { // 4. Current license allows access agreement // 5. And it's not a request to the Access Agreement UI itself return ( - canRedirectRequest(request) && - session != null && - !session.accessAgreementAcknowledged && - (this.options.config.authc.providers as Record)[session.provider.type]?.[ - session.provider.name + sessionValue != null && + !sessionValue.accessAgreementAcknowledged && + (this.options.config.authc.providers as Record)[sessionValue.provider.type]?.[ + sessionValue.provider.name ]?.accessAgreement && - this.options.license.getFeatures().allowAccessAgreement && - request.url.pathname !== ACCESS_AGREEMENT_ROUTE + this.options.license.getFeatures().allowAccessAgreement ); } + + /** + * In some cases we'd like to redirect user to another page right after successful authentication + * before they can access anything else in Kibana. This method makes sure we do a proper redirect + * that would eventually lead user to a initially requested Kibana URL. + * @param request Request instance. + * @param authenticationResult Result of the authentication. + * @param sessionUpdateResult Result of the session update. + * @param redirectURL + */ + private handlePreAccessRedirects( + request: KibanaRequest, + authenticationResult: AuthenticationResult, + sessionUpdateResult: { value: Readonly | null; overwritten: boolean } | null, + redirectURL?: string + ) { + if ( + authenticationResult.failed() || + request.url.pathname === ACCESS_AGREEMENT_ROUTE || + request.url.pathname === OVERWRITTEN_SESSION_ROUTE + ) { + return authenticationResult; + } + + let preAccessRedirectURL; + if (sessionUpdateResult?.overwritten) { + this.logger.debug('Redirecting user to the overwritten session UI.'); + preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; + } else if ( + authenticationResult.succeeded() && + this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) + ) { + this.logger.debug('Redirecting user to the access agreement UI.'); + preAccessRedirectURL = `${this.options.basePath.serverBasePath}${ACCESS_AGREEMENT_ROUTE}`; + } + + // If we need to redirect user to anywhere else before they can access Kibana we should remember + // redirect URL in the `next` parameter. Redirect URL provided in authentication result, if any, + // always takes precedence over what is specified in `redirectURL` parameter. + if (preAccessRedirectURL) { + preAccessRedirectURL = `${preAccessRedirectURL}?next=${encodeURIComponent( + authenticationResult.redirectURL || + redirectURL || + `${this.options.basePath.get(request)}${request.url.path}` + )}`; + } else if (redirectURL && !authenticationResult.redirectURL) { + preAccessRedirectURL = redirectURL; + } + + return preAccessRedirectURL + ? AuthenticationResult.redirectTo(preAccessRedirectURL, { + state: authenticationResult.state, + user: authenticationResult.user, + authResponseHeaders: authenticationResult.authResponseHeaders, + }) + : authenticationResult; + } } diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 7cd3ac18634f783..299a75335a64c38 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -18,7 +18,6 @@ export const authenticationMock = { invalidateAPIKey: jest.fn(), invalidateAPIKeyAsInternalUser: jest.fn(), isAuthenticated: jest.fn(), - getSessionInfo: jest.fn(), acknowledgeAccessAgreement: 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 56d44e6628a8724..8754082c94699f7 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { licenseMock } from '../../common/licensing/index.mock'; - jest.mock('./api_keys'); jest.mock('./authenticator'); @@ -18,17 +16,20 @@ import { httpServiceMock, elasticsearchServiceMock, } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { securityAuditLoggerMock } from '../audit/index.mock'; +import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { sessionMock } from '../session_management/session.mock'; import { AuthenticationHandler, AuthToolkit, ILegacyClusterClient, - CoreSetup, KibanaRequest, LoggerFactory, LegacyScopedClusterClient, + HttpServiceSetup, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; import { ConfigSchema, ConfigType, createConfig } from '../config'; @@ -43,17 +44,18 @@ import { import { SecurityLicense } from '../../common/licensing'; import { SecurityAuditLogger } from '../audit'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { Session } from '../session_management'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { auditLogger: jest.Mocked; config: ConfigType; loggers: LoggerFactory; - http: jest.Mocked; + http: jest.Mocked; clusterClient: jest.Mocked; license: jest.Mocked; getFeatureUsageService: () => jest.Mocked; + session: jest.Mocked>; }; let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { @@ -75,6 +77,7 @@ describe('setupAuthentication()', () => { getFeatureUsageService: jest .fn() .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), + session: sessionMock.create(), }; mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); @@ -85,33 +88,6 @@ describe('setupAuthentication()', () => { afterEach(() => jest.clearAllMocks()); - it('properly initializes session storage and registers auth handler', async () => { - const config = { - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }; - - await setupAuthentication(mockSetupAuthenticationParams); - - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( - expect.any(Function) - ); - - expect( - mockSetupAuthenticationParams.http.createCookieSessionStorageFactory - ).toHaveBeenCalledTimes(1); - expect( - mockSetupAuthenticationParams.http.createCookieSessionStorageFactory - ).toHaveBeenCalledWith({ - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - validate: expect.any(Function), - }); - }); - describe('authentication handler', () => { let authHandler: AuthenticationHandler; let authenticate: jest.SpyInstance, [KibanaRequest]>; @@ -121,6 +97,11 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] .authenticate; @@ -195,15 +176,20 @@ describe('setupAuthentication()', () => { expect(authenticate).toHaveBeenCalledWith(mockRequest); }); - it('redirects user if redirection is requested by the authenticator', async () => { + it('redirects user if redirection is requested by the authenticator preserving authentication response headers if any', async () => { const mockResponse = httpServerMock.createLifecycleResponseFactory(); - authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url')); + authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('/some/url', { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({ location: '/some/url', + 'WWW-Authenticate': 'Negotiate', }); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 659a378388a13c0..03ba5fa429389ed 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -6,24 +6,32 @@ import { UnwrapPromise } from '@kbn/utility-types'; import { ILegacyClusterClient, - CoreSetup, KibanaRequest, LoggerFactory, + HttpServiceSetup, } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; import { SecurityAuditLogger } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; -import { Authenticator, ProviderSession } from './authenticator'; -import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { Session } from '../session_management'; +import { Authenticator } from './authenticator'; +import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { OIDCLogin, SAMLLogin } from './providers'; +export { + OIDCLogin, + SAMLLogin, + BasicAuthenticationProvider, + TokenAuthenticationProvider, + SAMLAuthenticationProvider, + OIDCAuthenticationProvider, +} from './providers'; export { CreateAPIKeyResult, InvalidateAPIKeyResult, @@ -39,11 +47,12 @@ export { interface SetupAuthenticationParams { auditLogger: SecurityAuditLogger; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; - http: CoreSetup['http']; + http: HttpServiceSetup; clusterClient: ILegacyClusterClient; config: ConfigType; license: SecurityLicense; loggers: LoggerFactory; + session: PublicMethodsOf; } export type Authentication = UnwrapPromise>; @@ -56,6 +65,7 @@ export async function setupAuthentication({ config, license, loggers, + session, }: SetupAuthenticationParams) { const authLogger = loggers.get('authentication'); @@ -71,46 +81,16 @@ export async function setupAuthentication({ return (http.auth.get(request).state ?? null) as AuthenticatedUser | null; }; - const isValid = (sessionValue: ProviderSession) => { - // ensure that this cookie was created with the current Kibana configuration - const { path, idleTimeoutExpiration, lifespanExpiration } = sessionValue; - if (path !== undefined && path !== (http.basePath.serverBasePath || '/')) { - authLogger.debug(`Outdated session value with path "${sessionValue.path}"`); - return false; - } - // ensure that this cookie is not expired - if (idleTimeoutExpiration && idleTimeoutExpiration < Date.now()) { - return false; - } else if (lifespanExpiration && lifespanExpiration < Date.now()) { - return false; - } - return true; - }; - const authenticator = new Authenticator({ auditLogger, - getFeatureUsageService, - getCurrentUser, + loggers, clusterClient, basePath: http.basePath, - config: { session: config.session, authc: config.authc }, + config: { authc: config.authc }, + getCurrentUser, + getFeatureUsageService, license, - loggers, - sessionStorageFactory: await http.createCookieSessionStorageFactory({ - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - sameSite: config.sameSiteCookies, - validate: (session: ProviderSession | ProviderSession[]) => { - const array: ProviderSession[] = Array.isArray(session) ? session : [session]; - for (const sess of array) { - if (!isValid(sess)) { - return { isValid: false, path: sess.path }; - } - } - return { isValid: true }; - }, - }), + session, }); authLogger.debug('Successfully initialized authenticator.'); @@ -145,6 +125,7 @@ export async function setupAuthentication({ // decides what location user should be redirected to. return t.redirected({ location: authenticationResult.redirectURL!, + ...(authenticationResult.authResponseHeaders || {}), }); } @@ -180,7 +161,6 @@ export async function setupAuthentication({ return { login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), - getSessionInfo: authenticator.getSessionInfo.bind(authenticator), isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), acknowledgeAccessAgreement: authenticator.acknowledgeAccessAgreement.bind(authenticator), getCurrentUser, 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 aea5994e3ba3ef3..83e172c419a7dc2 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -12,31 +12,33 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, KibanaRequest, - ScopeableRequest, + ILegacyScopedClusterClient, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { OIDCAuthenticationProvider, OIDCLogin, 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'); -} +import { AuthenticatedUser } from '../../../common/model'; describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; + let mockUser: AuthenticatedUser; + let mockScopedClusterClient: jest.Mocked; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); + + mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockUser = mockAuthenticatedUser({ authentication_provider: 'oidc' }); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { + if (method === 'shield.authenticate') { + return mockUser; + } + + throw new Error(`Unexpected call to ${method}!`); + }); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); @@ -88,7 +90,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/mock-server-basepath/', + redirectURL: '/mock-server-basepath/', realm: 'oidc1', }, } @@ -118,7 +120,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.login(request, { type: OIDCLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/app/super-kibana', + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', }) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -132,7 +134,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/mock-server-basepath/app/super-kibana', + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', realm: 'oidc1', }, } @@ -144,6 +146,24 @@ describe('OIDCAuthenticationProvider', () => { }); }); + it('fails if OpenID Connect authentication request preparation fails.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + await expect( + provider.login(request, { + type: OIDCLogin.LoginInitiatedByUser, + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', + }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + }); + function defineAuthenticationFlowTests( getMocks: () => { request: KibanaRequest; @@ -163,7 +183,7 @@ describe('OIDCAuthenticationProvider', () => { provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/some-path', + redirectURL: '/base-path/some-path', realm: 'oidc1', }) ).resolves.toEqual( @@ -173,6 +193,7 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'some-refresh-token', realm: 'oidc1', }, + user: mockUser, }) ); @@ -193,7 +214,7 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { nextURL: '/base-path/some-path', realm: 'oidc1' }) + provider.login(request, attempt, { redirectURL: '/base-path/some-path', realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -251,7 +272,7 @@ describe('OIDCAuthenticationProvider', () => { provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/some-path', + redirectURL: '/base-path/some-path', realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -328,60 +349,25 @@ describe('OIDCAuthenticationProvider', () => { ); }); - it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { + it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - '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', + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); 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: '/mock-server-basepath/s/foo/some-path', - realm: 'oidc1', - }, - } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + { state: null } ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); - }); - - 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.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-valid-token', @@ -389,20 +375,13 @@ describe('OIDCAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'oidc' }, - { authHeaders: { authorization } } - ) + AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -446,36 +425,31 @@ describe('OIDCAuthenticationProvider', () => { const authorization = `Bearer ${tokenPair.accessToken}`; const failureReason = new Error('Token is not valid!'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; mockOptions.client.asScoped.mockImplementation((scopeableRequest) => { if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + const mockScopedClusterClientToFail = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClientToFail.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - return mockScopedClusterClient; + return mockScopedClusterClientToFail; } if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); return mockScopedClusterClient; } @@ -490,17 +464,14 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'oidc' }, - { - authHeaders: { authorization: 'Bearer new-access-token' }, - state: { - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - realm: 'oidc1', - }, - } - ) + AuthenticationResult.succeeded(mockUser, { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'oidc1', + }, + }) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -514,11 +485,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, @@ -533,32 +502,19 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); - it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { + it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => { 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.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - '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', - }); - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -566,19 +522,8 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).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: '/mock-server-basepath/s/foo/some-path', - realm: 'oidc1', - }, - } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + { state: null } ) ); @@ -592,9 +537,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -602,11 +545,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -619,7 +560,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { 'kbn-xsrf': 'xsrf', authorization }, }); @@ -631,11 +572,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -648,7 +587,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index ac7374401f99a12..8497bcc6ba46c3c 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import type from 'type-detect'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -32,7 +33,7 @@ export enum OIDCLogin { * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = - | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } + | { type: OIDCLogin.LoginInitiatedByUser; redirectURL: string } | { type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; @@ -58,7 +59,7 @@ interface ProviderState extends Partial { /** * URL to redirect user to after successful OpenID Connect handshake. */ - nextURL?: string; + redirectURL?: string; /** * The name of the OpenID Connect realm that was used to establish session. @@ -143,11 +144,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (attempt.type === OIDCLogin.LoginInitiatedByUser) { this.logger.debug(`Login has been initiated by a user.`); - return this.initiateOIDCAuthentication( - request, - { realm: this.realm }, - attempt.redirectURLPath - ); + return this.initiateOIDCAuthentication(request, { realm: this.realm }, attempt.redirectURL); } if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { @@ -200,7 +197,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) + ? await this.captureRedirectURL(request) : authenticationResult; } @@ -231,8 +228,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // If it is an authentication response and the users' session state doesn't contain all the necessary information, // then something unexpected happened and we should fail because Elasticsearch won't be able to validate the // response. - const { nonce: stateNonce = '', state: stateOIDCState = '', nextURL: stateRedirectURL = '' } = - sessionState || {}; + const { + nonce: stateNonce = '', + state: stateOIDCState = '', + redirectURL: stateRedirectURL = '', + } = sessionState || {}; if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; @@ -241,30 +241,47 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } // We have all the necessary parameters, so attempt to complete the OpenID Connect Authentication + let accessToken; + let refreshToken; try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/authenticate`. - const { - access_token: accessToken, - refresh_token: refreshToken, - } = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { - body: { - state: stateOIDCState, - nonce: stateNonce, - redirect_uri: authenticationResponseURI, - realm: this.realm, - }, - }); + const authenticateResponse = await this.options.client.callAsInternalUser( + 'shield.oidcAuthenticate', + { + body: { + state: stateOIDCState, + nonce: stateNonce, + redirect_uri: authenticationResponseURI, + realm: this.realm, + }, + } + ); - this.logger.debug('Request has been authenticated via OpenID Connect.'); + accessToken = authenticateResponse.access_token; + refreshToken = authenticateResponse.refresh_token; + } catch (err) { + this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + return AuthenticationResult.failed(err); + } - return AuthenticationResult.redirectTo(stateRedirectURL, { - state: { accessToken, refreshToken, realm: this.realm }, + // Now we need to retrieve full user information. + let user: Readonly; + try { + user = await this.getUser(request, { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), }); } catch (err) { - this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug(`Failed to retrieve user using access token: ${err.message}`); return AuthenticationResult.failed(err); } + + this.logger.debug('Login has been performed with OpenID Connect response.'); + + return AuthenticationResult.redirectTo(stateRedirectURL, { + state: { accessToken, refreshToken, realm: this.realm }, + user, + }); } /** @@ -272,13 +289,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful - * login. If not provided the URL of the specified request is used. + * @param redirectURL URL user is supposed to be redirected to after successful login. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` + redirectURL: string ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); @@ -295,7 +311,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } + { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -367,7 +383,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.initiateOIDCAuthentication(request, { realm: this.realm }); + return this.captureRedirectURL(request); } return AuthenticationResult.failed( @@ -447,4 +463,23 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public getHTTPAuthenticationScheme() { return 'bearer'; } + + /** + * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * @param request Request instance. + */ + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent( + this.options.name + )}`, + // Here we indicate that current session, if any, should be invalidated. It is a no-op for the + // initial handshake, but is essential when both access and refresh tokens are expired. + { state: null } + ); + } } 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 851ecf8107ad202..16043526fcd75d4 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,7 +5,6 @@ */ import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; @@ -13,33 +12,34 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, + ILegacyScopedClusterClient, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLogin } 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'); -} +import { AuthenticatedUser } from '../../../common/model'; describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; + let mockUser: AuthenticatedUser; + let mockScopedClusterClient: jest.Mocked; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); + + mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockUser = mockAuthenticatedUser({ authentication_provider: 'saml' }); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { + if (method === 'shield.authenticate') { + return mockUser; + } + + throw new Error(`Unexpected call to ${method}!`); + }); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), }); }); @@ -57,28 +57,11 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('throws if `maxRedirectURLSize` option is not specified', () => { - const providerOptions = mockAuthenticationProviderOptions(); - - expect( - () => new SAMLAuthenticationProvider(providerOptions, { realm: 'test-realm' }) - ).toThrowError('Maximum redirect URL size must be specified'); - - expect( - () => - new SAMLAuthenticationProvider(providerOptions, { - realm: 'test-realm', - maxRedirectURLSize: undefined, - }) - ).toThrowError('Maximum redirect URL size must be specified'); - }); - describe('`login` method', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', }); @@ -96,11 +79,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { state: { - username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -114,14 +97,12 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', }); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); await expect( @@ -141,11 +122,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { state: { - username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -214,6 +195,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'user-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -233,7 +215,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); await expect( @@ -253,6 +234,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'user-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -282,6 +264,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'idp-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -319,12 +302,6 @@ describe('SAMLAuthenticationProvider', () => { beforeEach(() => { mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => - Promise.resolve(mockAuthenticatedUser()) - ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'valid-token', @@ -333,7 +310,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); }); @@ -341,7 +317,6 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: false, }); @@ -354,11 +329,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -372,11 +347,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -391,11 +366,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -410,11 +385,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -431,11 +406,11 @@ describe('SAMLAuthenticationProvider', () => { `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, } ) ); @@ -447,11 +422,6 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; - const user = mockAuthenticatedUser(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const failureReason = new Error('SAML response is invalid!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); @@ -460,7 +430,6 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -468,8 +437,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.notHandled()); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -481,18 +449,12 @@ describe('SAMLAuthenticationProvider', () => { it('fails if fails to invalidate existing access/refresh tokens.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const user = mockAuthenticatedUser(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'new-valid-token', @@ -510,8 +472,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -527,25 +488,30 @@ describe('SAMLAuthenticationProvider', () => { }); for (const [description, response] of [ - ['session is valid', Promise.resolve({ username: 'user' })], [ - 'session is is expired', + 'current session is valid', + Promise.resolve(mockAuthenticatedUser({ authentication_provider: 'saml' })), + ], + [ + 'current session is is expired', Promise.reject(LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())), ], ] as Array<[string, Promise]>) { - it(`redirects to the home page if new SAML Response is for the same user if ${description}.`, async () => { + it(`redirects to the home page if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-token', refreshToken: 'existing-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + // The first call is made using tokens from existing session. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); + // The second call is made using new tokens. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(mockUser) + ); mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', @@ -564,16 +530,15 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { state: { - username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -588,19 +553,21 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it(`redirects to the URL from relay state if new SAML Response is for the same user if ${description}.`, async () => { + it(`redirects to the URL from relay state if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-token', refreshToken: 'existing-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + // The first call is made using tokens from existing session. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); + // The second call is made using new tokens. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(mockUser) + ); mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', @@ -612,7 +579,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); @@ -629,71 +595,15 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', { state: { - username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, - }); - }); - - it(`redirects to \`overwritten_session\` if new SAML Response is for the another user if ${description}.`, async () => { - const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - username: 'user', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'new-user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - }); - - mockOptions.tokens.invalidate.mockResolvedValue(undefined); - - await expect( - provider.login( - request, - { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, - state - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo('/mock-server-basepath/security/overwritten_session', { - state: { - username: 'new-user', - accessToken: 'new-valid-token', - refreshToken: 'new-valid-refresh-token', - realm: 'test-realm', - }, - }) - ); - - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -711,24 +621,24 @@ describe('SAMLAuthenticationProvider', () => { }); describe('User initiated login with captured redirect URL', () => { - it('fails if redirectURLPath is not available', async () => { + it('fails if redirectURL is not valid', async () => { const request = httpServerMock.createKibanaRequest(); await expect( provider.login(request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '', }) ).resolves.toEqual( AuthenticationResult.failed( - Boom.badRequest('State or login attempt does not include URL path to redirect to.') + Boom.badRequest('Login attempt should include non-empty `redirectURL` string.') ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('redirects requests to the IdP remembering combined redirect URL.', async () => { + it('redirects requests to the IdP remembering redirect URL with existing state.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -741,9 +651,9 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } + { realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -765,7 +675,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => { + it('redirects requests to the IdP remembering redirect URL without state.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -778,8 +688,7 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: '/test-base-path/some-path', - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, null ) @@ -803,120 +712,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('prepends redirect URL fragment with `#` if it does not have one.', async () => { - const request = httpServerMock.createKibanaRequest(); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '../some-fragment', - }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } - ) - ).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', - 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 `#`.' - ); - }); - - it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { - const request = httpServerMock.createKibanaRequest(); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment'.repeat(10), - }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { - state: { - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path', - 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.' - ); - }); - - it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`, - redirectURLFragment: '#some-fragment', - }, - null - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '', 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 path size should not exceed 100b but it was 106b. URL is not captured.' - ); - }); - it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -928,9 +723,9 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } + { realm: 'test-realm' } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -977,7 +772,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.authenticate(request, { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -988,7 +782,7 @@ describe('SAMLAuthenticationProvider', () => { expect(request.headers.authorization).toBe('Bearer some-token'); }); - it('redirects non-AJAX request that can not be authenticated to the "capture fragment" page.', async () => { + it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -998,81 +792,28 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + { state: null } ) ); 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 () => { - const request = httpServerMock.createKibanaRequest({ - path: `/s/foo/${'some-path'.repeat(10)}`, - }); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '', 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 path size should not exceed 100b but it was 118b. URL is not captured.' - ); - }); - - it('fails if SAML request preparation fails.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: `/s/foo/${'some-path'.repeat(10)}`, - }); - - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - }); - it('succeeds if state contains a valid token.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - 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 } } - ) + AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1080,7 +821,6 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is rejected because of unknown reason.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -1088,24 +828,20 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.failed(failureReason as any) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', @@ -1113,16 +849,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asScoped.mockImplementation((scopeableRequest) => { if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + const mockScopedClusterClientToFail = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClientToFail.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - return mockScopedClusterClient; + return mockScopedClusterClientToFail; } if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); return mockScopedClusterClient; } @@ -1135,18 +869,14 @@ describe('SAMLAuthenticationProvider', () => { }); 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', - realm: 'test-realm', - }, - } - ) + AuthenticationResult.succeeded(mockUser, { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'test-realm', + }, + }) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -1158,18 +888,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, @@ -1184,7 +911,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1192,18 +919,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -1214,7 +938,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { 'kbn-xsrf': 'xsrf', authorization }, }); @@ -1224,18 +948,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -1246,9 +967,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { - headers: { authorization }, - }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1256,84 +975,33 @@ describe('SAMLAuthenticationProvider', () => { it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + { state: null } ) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ 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', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) - ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - - mockOptions.tokens.refresh.mockResolvedValue(null); - - 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: '', realm: 'test-realm' } } - ) - ); - - expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 118b. URL is not captured.' - ); - }); - it('fails if realm from state is different from the realm provider is configured with.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( @@ -1371,7 +1039,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1409,7 +1076,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1431,7 +1097,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1455,7 +1120,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1475,7 +1139,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', realm: 'test-realm', @@ -1539,7 +1202,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1560,7 +1222,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', realm: 'test-realm', diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index d121cd4979aa738..40f86aa5e20c667 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -5,8 +5,8 @@ */ import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { isInternalURL } from '../../../common/is_internal_url'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -19,15 +19,11 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './bas * The state supported by the provider (for the SAML handshake or established session). */ interface ProviderState extends Partial { - /** - * Username of the SAML authenticated user. - */ - username?: string; - /** * Unique identifier of the SAML request initiated the handshake. */ requestId?: string; + /** * Stores path component of the URL only or in a combination with URL fragment that was used to * initiate SAML handshake and where we should redirect user after successful authentication. @@ -59,7 +55,7 @@ export enum SAMLLogin { * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginInitiatedByUser; redirectURL: string } | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string; relayState?: string }; /** @@ -102,11 +98,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private readonly realm: string; - /** - * Maximum size of the URL we store in the session during SAML handshake. - */ - private readonly maxRedirectURLSize: ByteSizeValue; - /** * Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect * user to after successful IdP initiated login. `RelayState` is ignored for SP initiated login. @@ -115,11 +106,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { constructor( protected readonly options: Readonly, - samlOptions?: Readonly<{ - realm?: string; - maxRedirectURLSize?: ByteSizeValue; - useRelayStateDeepLink?: boolean; - }> + samlOptions?: Readonly<{ realm?: string; useRelayStateDeepLink?: boolean }> ) { super(options); @@ -127,12 +114,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { throw new Error('Realm name must be specified'); } - if (!samlOptions.maxRedirectURLSize) { - throw new Error('Maximum redirect URL size must be specified'); - } - this.realm = samlOptions.realm; - this.maxRedirectURLSize = samlOptions.maxRedirectURLSize; this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false; } @@ -158,14 +140,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } if (attempt.type === SAMLLogin.LoginInitiatedByUser) { - const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; - if (!redirectURLPath) { - const message = 'State or login attempt does not include URL path to redirect to.'; + if (!attempt.redirectURL) { + const message = 'Login attempt should include non-empty `redirectURL` string.'; this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } - - return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); + return this.authenticateViaHandshake(request, attempt.redirectURL); } const { samlResponse, relayState } = attempt; @@ -354,46 +334,24 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { : 'Login has been initiated by Identity Provider.' ); + let accessToken; + let refreshToken; try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`. - const { - username, - access_token: accessToken, - refresh_token: refreshToken, - } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { - body: { - ids: !isIdPInitiatedLogin ? [stateRequestId] : [], - content: samlResponse, - realm: this.realm, - }, - }); - - // IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and - // depending on the configuration we may need to redirect user to this URL. - let redirectURLFromRelayState; - if (isIdPInitiatedLogin && relayState) { - if (!this.useRelayStateDeepLink) { - this.options.logger.debug( - `"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.` - ); - } else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) { - this.options.logger.debug( - `"RelayState" is provided, but it is not a valid Kibana internal URL.` - ); - } else { - this.options.logger.debug( - `User will be redirected to the Kibana internal URL specified in "RelayState".` - ); - redirectURLFromRelayState = relayState; + const authenticateResponse = await this.options.client.callAsInternalUser( + 'shield.samlAuthenticate', + { + body: { + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], + content: samlResponse, + realm: this.realm, + }, } - } - - this.logger.debug('Login has been performed with SAML response.'); - return AuthenticationResult.redirectTo( - redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, - { state: { username, accessToken, refreshToken, realm: this.realm } } ); + + accessToken = authenticateResponse.access_token; + refreshToken = authenticateResponse.refresh_token; } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); @@ -404,6 +362,43 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ? AuthenticationResult.notHandled() : AuthenticationResult.failed(err); } + + // Now we need to retrieve full user information. + let user: Readonly; + try { + user = await this.getUser(request, { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }); + } catch (err) { + this.logger.debug(`Failed to retrieve user using access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + + // IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and + // depending on the configuration we may need to redirect user to this URL. + let redirectURLFromRelayState; + if (isIdPInitiatedLogin && relayState) { + if (!this.useRelayStateDeepLink) { + this.options.logger.debug( + `"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.` + ); + } else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) { + this.options.logger.debug( + `"RelayState" is provided, but it is not a valid Kibana internal URL.` + ); + } else { + this.options.logger.debug( + `User will be redirected to the Kibana internal URL specified in "RelayState".` + ); + redirectURLFromRelayState = relayState; + } + } + + this.logger.debug('Login has been performed with SAML response.'); + return AuthenticationResult.redirectTo( + redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, + { state: { accessToken, refreshToken, realm: this.realm }, user } + ); } /** @@ -444,8 +439,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ); } - const newState = payloadAuthenticationResult.state as ProviderState; - // Now let's invalidate tokens from the existing session. try { this.logger.debug('Perform IdP initiated local logout.'); @@ -458,17 +451,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.failed(err); } - if (newState.username !== existingState.username) { - this.logger.debug( - 'Login initiated by Identity Provider is for a different user than currently authenticated.' - ); - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/overwritten_session`, - { state: newState } - ); - } - - this.logger.debug('Login initiated by Identity Provider is for currently authenticated user.'); + this.logger.debug('Login initiated by Identity Provider is successfully completed.'); return payloadAuthenticationResult; } @@ -509,7 +492,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private async authenticateViaRefreshToken( request: KibanaRequest, - { username, refreshToken }: ProviderState + { refreshToken }: ProviderState ) { this.logger.debug('Trying to refresh access token.'); @@ -555,7 +538,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via refreshed token.'); return AuthenticationResult.succeeded(user, { authHeaders, - state: { username, realm: this.realm, ...refreshedTokenPair }, + state: { realm: this.realm, ...refreshedTokenPair }, }); } catch (err) { this.logger.debug( @@ -640,52 +623,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { /** * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. - * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful - * login. If not provided the URL path of the specified request is used. - * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected - * to after successful login. If not provided user will be redirected to the client-side page that - * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL( - request: KibanaRequest, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, - redirectURLFragment?: string - ) { - // If the size of the path already exceeds the maximum allowed size of the URL to store in the - // session there is no reason to try to capture URL fragment and we start handshake immediately. - // In this case user will be redirected to the Kibana home/root after successful login. - let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` - ); - return this.authenticateViaHandshake(request, ''); - } - - // If URL fragment wasn't specified at all, let's try to capture it. - if (redirectURLFragment === undefined) { - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, - { state: { redirectURL: redirectURLPath, realm: this.realm } } - ); - } - - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${redirectURLPath}${redirectURLFragment}`; - redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = redirectURLPath; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent( + this.options.name + )}`, + // Here we indicate that current session, if any, should be invalidated. It is a no-op for the + // initial handshake, but is essential when both access and refresh tokens are expired. + { state: null } + ); } } diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index f67e0863086bb2c..2fdc2d169e97200 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -13,8 +13,8 @@ import { mockRegisterPrivilegesWithCluster, } from './service.test.mocks'; -import { BehaviorSubject } from 'rxjs'; -import { CoreStatus, ServiceStatusLevels } from '../../../../../src/core/server'; +import { Subject } from 'rxjs'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; @@ -22,6 +22,7 @@ import { authorizationModeFactory } from './mode'; import { privilegesFactory } from './privileges'; import { AuthorizationService } from '.'; +import { nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, elasticsearchServiceMock, @@ -29,8 +30,6 @@ import { } from '../../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../../features/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; -import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; -import { nextTick } from 'test_utils/enzyme_helpers'; const kibanaIndexName = '.a-kibana-index'; const application = `kibana-${kibanaIndexName}`; @@ -68,7 +67,6 @@ it(`#setup returns exposed services`, () => { const authz = authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, license: mockLicense, loggers: loggingSystemMock.create(), @@ -115,31 +113,19 @@ it(`#setup returns exposed services`, () => { }); describe('#start', () => { - let statusSubject: BehaviorSubject; - let licenseSubject: BehaviorSubject; - let mockLicense: jest.Mocked; + let statusSubject: Subject; beforeEach(() => { - const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + statusSubject = new Subject(); - licenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); - mockLicense = licenseMock.create(); - mockLicense.isEnabled.mockReturnValue(false); - mockLicense.features$ = licenseSubject; - - statusSubject = new BehaviorSubject({ - elasticsearch: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); + const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); const mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.status.core$ = statusSubject; const authorizationService = new AuthorizationService(); authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, - license: mockLicense, + license: licenseMock.create(), loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', @@ -152,95 +138,64 @@ describe('#start', () => { const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, features: featuresStart }); + authorizationService.start({ + clusterClient: mockClusterClient, + features: featuresStart, + online$: statusSubject.asObservable(), + }); // ES and license aren't available yet. expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); }); it('registers cluster privileges', async () => { - // ES is available now, but not license. - statusSubject.next({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); - expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); - - // Both ES and license are available now. - mockLicense.isEnabled.mockReturnValue(true); - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - await nextTick(); - // New changes still trigger privileges re-registration. - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + statusSubject.next({ scheduleRetry: retryScheduler }); expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + expect(retryScheduler).not.toHaveBeenCalled(); }); it('schedules retries if fails to register cluster privileges', async () => { - jest.useFakeTimers(); - mockRegisterPrivilegesWithCluster.mockRejectedValue(new Error('Some error')); - // Both ES and license are available. - mockLicense.isEnabled.mockReturnValue(true); - statusSubject.next({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - - // Next retry isn't performed immediately, retry happens only after a timeout. + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(100); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + expect(retryScheduler).toHaveBeenCalledTimes(1); - // Delay between consequent retries is increasing. + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); - jest.advanceTimersByTime(100); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); - await nextTick(); - jest.advanceTimersByTime(100); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(3); + expect(retryScheduler).toHaveBeenCalledTimes(2); // When call finally succeeds retries aren't scheduled anymore. mockRegisterPrivilegesWithCluster.mockResolvedValue(undefined); + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); - jest.runAllTimers(); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); - await nextTick(); - jest.runAllTimers(); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); - // New changes still trigger privileges re-registration. - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(5); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(3); + expect(retryScheduler).toHaveBeenCalledTimes(2); }); }); -it('#stop unsubscribes from license and ES updates.', () => { +it('#stop unsubscribes from license and ES updates.', async () => { const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); - - const licenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); - const mockLicense = licenseMock.create(); - mockLicense.isEnabled.mockReturnValue(false); - mockLicense.features$ = licenseSubject; - + const statusSubject = new Subject(); const mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.status.core$ = new BehaviorSubject({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - }); const authorizationService = new AuthorizationService(); authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, - license: mockLicense, + license: licenseMock.create(), loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', @@ -252,12 +207,19 @@ it('#stop unsubscribes from license and ES updates.', () => { const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, features: featuresStart }); + authorizationService.start({ + clusterClient: mockClusterClient, + features: featuresStart, + online$: statusSubject.asObservable(), + }); authorizationService.stop(); - // After stop we don't register privileges even if all requirements are met. - mockLicense.isEnabled.mockReturnValue(true); - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + // After stop we don't register privileges even if status changes. + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); + await nextTick(); + expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); + expect(retryScheduler).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.ts index cae273ecac3383f..4190499cbd5f435 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.ts @@ -4,16 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineLatest, BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter } from 'rxjs/operators'; +import { Subscription, Observable } from 'rxjs'; import { UICapabilities } from 'ui/capabilities'; import { LoggerFactory, KibanaRequest, ILegacyClusterClient, - ServiceStatusLevels, Logger, - StatusServiceSetup, HttpServiceSetup, CapabilitiesSetup, } from '../../../../../src/core/server'; @@ -44,6 +41,7 @@ import { validateReservedPrivileges } from './validate_reserved_privileges'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; @@ -52,7 +50,6 @@ export { featurePrivilegeIterator } from './privileges'; interface AuthorizationServiceSetupParams { packageVersion: string; http: HttpServiceSetup; - status: StatusServiceSetup; capabilities: CapabilitiesSetup; clusterClient: ILegacyClusterClient; license: SecurityLicense; @@ -65,6 +62,7 @@ interface AuthorizationServiceSetupParams { interface AuthorizationServiceStartParams { features: FeaturesPluginStart; clusterClient: ILegacyClusterClient; + online$: Observable; } export interface AuthorizationServiceSetup { @@ -79,8 +77,6 @@ export interface AuthorizationServiceSetup { export class AuthorizationService { private logger!: Logger; - private license!: SecurityLicense; - private status!: StatusServiceSetup; private applicationName!: string; private privileges!: PrivilegesService; @@ -89,7 +85,6 @@ export class AuthorizationService { setup({ http, capabilities, - status, packageVersion, clusterClient, license, @@ -99,8 +94,6 @@ export class AuthorizationService { getSpacesService, }: AuthorizationServiceSetupParams): AuthorizationServiceSetup { this.logger = loggers.get('authorization'); - this.license = license; - this.status = status; this.applicationName = `${APPLICATION_PREFIX}${kibanaIndexName}`; const mode = authorizationModeFactory(license); @@ -158,12 +151,23 @@ export class AuthorizationService { return authz; } - start({ clusterClient, features }: AuthorizationServiceStartParams) { + start({ clusterClient, features, online$ }: AuthorizationServiceStartParams) { const allFeatures = features.getFeatures(); validateFeaturePrivileges(allFeatures); validateReservedPrivileges(allFeatures); - this.registerPrivileges(clusterClient); + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { + try { + await registerPrivilegesWithCluster( + this.logger, + this.privileges, + this.applicationName, + clusterClient + ); + } catch (err) { + scheduleRetry(); + } + }); } stop() { @@ -172,50 +176,4 @@ export class AuthorizationService { this.statusSubscription = undefined; } } - - private registerPrivileges(clusterClient: ILegacyClusterClient) { - const RETRY_SCALE_DURATION = 100; - const RETRY_TIMEOUT_MAX = 10000; - const retries$ = new BehaviorSubject(0); - let retryTimeout: NodeJS.Timeout; - - // Register cluster privileges once Elasticsearch is available and Security plugin is enabled. - this.statusSubscription = combineLatest([ - this.status.core$, - this.license.features$, - retries$.asObservable().pipe( - // We shouldn't emit new value if retry counter is reset. This comparator isn't called for - // the initial value. - distinctUntilChanged((prev, curr) => prev === curr || curr === 0) - ), - ]) - .pipe( - filter( - ([status]) => - this.license.isEnabled() && status.elasticsearch.level === ServiceStatusLevels.available - ) - ) - .subscribe(async () => { - // If status or license change occurred before retry timeout we should cancel it. - if (retryTimeout) { - clearTimeout(retryTimeout); - } - - try { - await registerPrivilegesWithCluster( - this.logger, - this.privileges, - this.applicationName, - clusterClient - ); - retries$.next(0); - } catch (err) { - const retriesElapsed = retries$.getValue() + 1; - retryTimeout = setTimeout( - () => retries$.next(retriesElapsed), - Math.min(retriesElapsed * RETRY_SCALE_DURATION, RETRY_TIMEOUT_MAX) - ); - } - }); - } } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 6ba33b2cccb7cb1..b1200d26b8379bc 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -272,9 +272,6 @@ describe('config schema', () => { "saml", ], "saml": Object { - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "realm": "realm-1", }, "selector": Object {}, @@ -294,13 +291,10 @@ describe('config schema', () => { authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, }).authc.saml ).toMatchInlineSnapshot(` - Object { - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, - "realm": "realm-1", - } - `); + Object { + "realm": "realm-1", + } + `); expect( ConfigSchema.validate({ @@ -665,9 +659,6 @@ describe('config schema', () => { "saml": Object { "saml1": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 0, "realm": "saml1", "showInSelector": true, @@ -685,9 +676,6 @@ describe('config schema', () => { }, "saml3": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 2, "realm": "saml3", "showInSelector": true, @@ -774,9 +762,6 @@ describe('config schema', () => { "saml": Object { "basic1": Object { "enabled": false, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 3, "realm": "saml3", "showInSelector": true, @@ -784,9 +769,6 @@ describe('config schema', () => { }, "saml1": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 1, "realm": "saml1", "showInSelector": true, @@ -794,9 +776,6 @@ describe('config schema', () => { }, "saml2": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 2, "realm": "saml2", "showInSelector": true, @@ -901,9 +880,6 @@ describe('createConfig()', () => { "saml": Object { "saml": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 0, "realm": "saml-realm", "showInSelector": true, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 051a3d2ab134224..0b6397aa631843e 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -96,7 +96,7 @@ const providersConfigSchema = schema.object( schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + maxRedirectURLSize: schema.maybe(schema.byteSize()), useRelayStateDeepLink: schema.boolean({ defaultValue: false }), }) ) @@ -181,7 +181,7 @@ export const ConfigSchema = schema.object({ 'saml', schema.object({ realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + maxRedirectURLSize: schema.maybe(schema.byteSize()), }) ), http: schema.object({ diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts similarity index 100% rename from x-pack/plugins/security/server/elasticsearch_client_plugin.ts rename to x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts new file mode 100644 index 000000000000000..792b54ec553a6d8 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; +import { ElasticsearchService } from './elasticsearch_service'; + +import { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; + +describe('ElasticsearchService', () => { + let service: ElasticsearchService; + beforeEach(() => { + service = new ElasticsearchService(loggingSystemMock.createLogger()); + }); + + describe('setup()', () => { + it('exposes proper contract', async () => { + const mockCoreSetup = coreMock.createSetup(); + const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); + + await expect( + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: licenseMock.create(), + }) + ).toEqual({ clusterClient: mockClusterClient }); + + expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); + expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { + plugins: [elasticsearchClientPlugin], + }); + }); + }); + + describe('start', () => { + /* +it('schedules retries if fails to register cluster privileges', async () => { + jest.useFakeTimers(); + + mockRegisterPrivilegesWithCluster.mockRejectedValue(new Error('Some error')); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + statusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); + + // Next retry isn't performed immediately, retry happens only after a timeout. + await nextTick(); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(100); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + + // Delay between consequent retries is increasing. + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(3); + + // When call finally succeeds retries aren't scheduled anymore. + mockRegisterPrivilegesWithCluster.mockResolvedValue(undefined); + await nextTick(); + jest.runAllTimers(); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); + await nextTick(); + jest.runAllTimers(); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); + + // New changes still trigger privileges re-registration. + licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(5); +});*/ + }); + + describe('stop()', () => { + it('properly closes cluster client instance', async () => { + const mockCoreSetup = coreMock.createSetup(); + const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); + + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: licenseMock.create(), + }); + + expect(mockClusterClient.close).not.toHaveBeenCalled(); + + await service.stop(); + + expect(mockClusterClient.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts new file mode 100644 index 000000000000000..291c6e9e3a6db24 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -0,0 +1,124 @@ +/* + * 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 { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay, tap } from 'rxjs/operators'; +import { + ILegacyClusterClient, + ILegacyCustomClusterClient, + Logger, + ServiceStatusLevels, + StatusServiceSetup, + ElasticsearchServiceSetup as CoreElasticsearchServiceSetup, +} from '../../../../../src/core/server'; +import { SecurityLicense } from '../../common/licensing'; +import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; + +export interface ElasticsearchServiceSetupParams { + readonly elasticsearch: CoreElasticsearchServiceSetup; + readonly status: StatusServiceSetup; + readonly license: SecurityLicense; +} + +export interface ElasticsearchServiceSetup { + readonly clusterClient: ILegacyClusterClient; +} + +export interface ElasticsearchServiceStart { + readonly clusterClient: ILegacyClusterClient; + readonly watchOnlineStatus$: () => Observable; +} + +export interface OnlineStatusRetryScheduler { + scheduleRetry: () => void; +} + +export class ElasticsearchService { + readonly #logger: Logger; + #clusterClient?: ILegacyCustomClusterClient; + #coreStatus$!: Observable; + + constructor(logger: Logger) { + this.#logger = logger; + } + + setup({ + elasticsearch, + status, + license, + }: ElasticsearchServiceSetupParams): ElasticsearchServiceSetup { + this.#clusterClient = elasticsearch.legacy.createClient('security', { + plugins: [elasticsearchClientPlugin], + }); + + this.#coreStatus$ = combineLatest([status.core$, license.features$]).pipe( + map( + ([coreStatus]) => + license.isEnabled() && coreStatus.elasticsearch.level === ServiceStatusLevels.available + ), + shareReplay(1) + ); + + return { clusterClient: this.#clusterClient }; + } + + start(): ElasticsearchServiceStart { + return { + clusterClient: this.#clusterClient!, + watchOnlineStatus$: () => { + const RETRY_SCALE_DURATION = 100; + const RETRY_TIMEOUT_MAX = 10000; + const retries$ = new BehaviorSubject(0); + + const retryScheduler = { + scheduleRetry: () => { + const retriesElapsed = retries$.getValue() + 1; + const nextRetryTimeout = Math.min( + retriesElapsed * RETRY_SCALE_DURATION, + RETRY_TIMEOUT_MAX + ); + + this.#logger.debug(`Scheduling re-try in ${nextRetryTimeout} ms.`); + + retryTimeout = setTimeout(() => retries$.next(retriesElapsed), nextRetryTimeout); + }, + }; + + let retryTimeout: NodeJS.Timeout; + return combineLatest([ + this.#coreStatus$.pipe( + tap(() => { + // If status or license change occurred before retry timeout we should cancel + // it and reset retry counter. + if (retryTimeout) { + clearTimeout(retryTimeout); + } + + if (retries$.value > 0) { + retries$.next(0); + } + }) + ), + retries$.asObservable().pipe( + // We shouldn't emit new value if retry counter is reset. This comparator isn't called for + // the initial value. + distinctUntilChanged((prev, curr) => prev === curr || curr === 0) + ), + ]).pipe( + filter(([isAvailable]) => isAvailable), + map(() => retryScheduler) + ); + }, + }; + } + + stop() { + if (this.#clusterClient) { + this.#clusterClient.close(); + this.#clusterClient = undefined; + } + } +} diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts new file mode 100644 index 000000000000000..793bdc1c6ad2612 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { + ElasticsearchService, + ElasticsearchServiceSetup, + ElasticsearchServiceStart, + OnlineStatusRetryScheduler, +} from './elasticsearch_service'; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index d357519c5cccee1..00ad962115901f1 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -36,6 +36,7 @@ export const config: PluginConfigDescriptor> = { deprecations: ({ rename, unused }) => [ rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), + unused('authc.saml.maxRedirectURLSize'), // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. (settings, fromPath, log) => { if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { @@ -65,6 +66,19 @@ export const config: PluginConfigDescriptor> = { } return settings; }, + (settings, fromPath, log) => { + const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< + string, + any + >; + if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { + log( + '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' + ); + } + + return settings; + }, ], exposeToBrowser: { loginAssistanceMessage: true, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a7b958ee02de503..d2666c191634174 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -7,7 +7,7 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; import { ILegacyCustomClusterClient } from '../../../../src/core/server'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; +import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; @@ -19,20 +19,15 @@ describe('Security Plugin', () => { let mockDependencies: PluginSetupDependencies; beforeEach(() => { plugin = new Plugin( - coreMock.createPluginInitializerContext({ - cookieName: 'sid', - session: { - idleTimeout: 1500, - lifespan: null, - }, - audit: { enabled: false }, - authc: { - selector: { enabled: false }, - providers: ['saml', 'token'], - saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, - http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, - }, - }) + coreMock.createPluginInitializerContext( + ConfigSchema.validate({ + session: { idleTimeout: 1500 }, + authc: { + providers: ['saml', 'token'], + saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, + }, + }) + ) ); mockCoreSetup = coreMock.createSetup(); @@ -112,26 +107,13 @@ describe('Security Plugin', () => { } `); }); - - it('properly creates cluster client instance', async () => { - await plugin.setup(mockCoreSetup, mockDependencies); - - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { - plugins: [elasticsearchClientPlugin], - }); - }); }); describe('stop()', () => { beforeEach(async () => await plugin.setup(mockCoreSetup, mockDependencies)); - it('properly closes cluster client instance', async () => { - expect(mockClusterClient.close).not.toHaveBeenCalled(); - + it('close does not throw', async () => { await plugin.stop(); - - expect(mockClusterClient.close).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index c4b16c9eec87298..b43a1d9a8ea3969 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -9,7 +9,6 @@ import { first, map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; import { deepFreeze, - ILegacyCustomClusterClient, CoreSetup, CoreStart, Logger, @@ -29,8 +28,9 @@ import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; +import { ElasticsearchService } from './elasticsearch'; +import { SessionManagementService } from './session_management'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -81,7 +81,6 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private clusterClient?: ILegacyCustomClusterClient; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; @@ -96,6 +95,12 @@ export class Plugin { private readonly auditService = new AuditService(this.initializerContext.logger.get('audit')); private readonly authorizationService = new AuthorizationService(); + private readonly elasticsearchService = new ElasticsearchService( + this.initializerContext.logger.get('elasticsearch') + ); + private readonly sessionManagementService = new SessionManagementService( + this.initializerContext.logger.get('session') + ); private readonly getSpacesService = () => { // Changing property value from Symbol to undefined denotes the fact that property was accessed. @@ -127,35 +132,44 @@ export class Plugin { .pipe(first()) .toPromise(); - this.clusterClient = core.elasticsearch.legacy.createClient('security', { - plugins: [elasticsearchClientPlugin], - }); - this.securityLicenseService = new SecurityLicenseService(); const { license } = this.securityLicenseService.setup({ license$: licensing.license$, }); + const { clusterClient } = this.elasticsearchService.setup({ + elasticsearch: core.elasticsearch, + license, + status: core.status, + }); + this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); const audit = this.auditService.setup({ license, config: config.audit }); const auditLogger = new SecurityAuditLogger(audit.getLogger()); + const { session } = this.sessionManagementService.setup({ + auditLogger, + config, + clusterClient, + http: core.http, + }); + const authc = await setupAuthentication({ auditLogger, getFeatureUsageService: this.getFeatureUsageService, http: core.http, - clusterClient: this.clusterClient, + clusterClient, config, license, loggers: this.initializerContext.logger, + session, }); const authz = this.authorizationService.setup({ http: core.http, capabilities: core.capabilities, - status: core.status, - clusterClient: this.clusterClient, + clusterClient, license, loggers: this.initializerContext.logger, kibanaIndexName: legacyConfig.kibana.index, @@ -176,11 +190,12 @@ export class Plugin { basePath: core.http.basePath, httpResources: core.http.resources, logger: this.initializerContext.logger.get('routes'), - clusterClient: this.clusterClient, + clusterClient, config, authc, authz, license, + session, getFeatures: () => core .getStartServices() @@ -223,20 +238,20 @@ export class Plugin { public start(core: CoreStart, { features, licensing }: PluginStartDependencies) { this.logger.debug('Starting plugin'); + this.featureUsageServiceStart = this.featureUsageService.start({ featureUsage: licensing.featureUsage, }); - this.authorizationService.start({ features, clusterClient: this.clusterClient! }); + + const { clusterClient, watchOnlineStatus$ } = this.elasticsearchService.start(); + + this.sessionManagementService.start({ online$: watchOnlineStatus$() }); + this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); } public stop() { this.logger.debug('Stopping plugin'); - if (this.clusterClient) { - this.clusterClient.close(); - this.clusterClient = undefined; - } - if (this.securityLicenseService) { this.securityLicenseService.stop(); this.securityLicenseService = undefined; @@ -245,8 +260,11 @@ export class Plugin { if (this.featureUsageServiceStart) { this.featureUsageServiceStart = undefined; } + this.auditService.stop(); this.authorizationService.stop(); + this.elasticsearchService.stop(); + this.sessionManagementService.stop(); } private wasSpacesServiceAccessed() { diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts deleted file mode 100644 index 944bc567de5860b..000000000000000 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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 { Type } from '@kbn/config-schema'; -import { - IRouter, - kibanaResponseFactory, - RequestHandler, - RequestHandlerContext, - RouteConfig, -} from '../../../../../../src/core/server'; -import { Authentication, AuthenticationResult } from '../../authentication'; -import { defineBasicRoutes } from './basic'; - -import { httpServerMock } from '../../../../../../src/core/server/mocks'; -import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { routeDefinitionParamsMock } from '../index.mock'; - -describe('Basic authentication routes', () => { - let router: jest.Mocked; - let authc: jest.Mocked; - let mockContext: RequestHandlerContext; - beforeEach(() => { - const routeParamsMock = routeDefinitionParamsMock.create(); - router = routeParamsMock.router; - - authc = routeParamsMock.authc; - authc.isProviderTypeEnabled.mockImplementation((provider) => provider === 'basic'); - - mockContext = ({ - licensing: { - license: { check: jest.fn().mockReturnValue({ check: 'valid' }) }, - }, - } as unknown) as RequestHandlerContext; - - defineBasicRoutes(routeParamsMock); - }); - - describe('login', () => { - let routeHandler: RequestHandler; - let routeConfig: RouteConfig; - - const mockRequest = httpServerMock.createKibanaRequest({ - body: { username: 'user', password: 'password' }, - }); - - beforeEach(() => { - const [loginRouteConfig, loginRouteHandler] = router.post.mock.calls.find( - ([{ path }]) => path === '/internal/security/login' - )!; - - routeConfig = loginRouteConfig; - routeHandler = loginRouteHandler; - }); - - it('correctly defines route.', async () => { - expect(routeConfig.options).toEqual({ authRequired: false }); - expect(routeConfig.validate).toEqual({ - body: expect.any(Type), - query: undefined, - params: undefined, - }); - - const bodyValidator = (routeConfig.validate as any).body as Type; - expect(bodyValidator.validate({ username: 'user', password: 'password' })).toEqual({ - username: 'user', - password: 'password', - }); - - expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( - `"[username]: expected value of type [string] but got [undefined]"` - ); - expect(() => bodyValidator.validate({ username: 'user' })).toThrowErrorMatchingInlineSnapshot( - `"[password]: expected value of type [string] but got [undefined]"` - ); - expect(() => - bodyValidator.validate({ password: 'password' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: expected value of type [string] but got [undefined]"` - ); - expect(() => - bodyValidator.validate({ username: '', password: '' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value has length [0] but it must have a minimum length of [1]."` - ); - expect(() => - bodyValidator.validate({ username: 'user', password: '' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[password]: value has length [0] but it must have a minimum length of [1]."` - ); - expect(() => - bodyValidator.validate({ username: '', password: 'password' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value has length [0] but it must have a minimum length of [1]."` - ); - }); - - it('returns 500 if authentication throws unhandled exception.', async () => { - const unhandledException = new Error('Something went wrong.'); - authc.login.mockRejectedValue(unhandledException); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(500); - expect(response.payload).toEqual(unhandledException); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('returns 401 if authentication fails.', async () => { - const failureReason = new Error('Something went wrong.'); - authc.login.mockResolvedValue(AuthenticationResult.failed(failureReason)); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(401); - expect(response.payload).toEqual(failureReason); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('returns 401 if authentication is not handled.', async () => { - authc.login.mockResolvedValue(AuthenticationResult.notHandled()); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(401); - expect(response.payload).toEqual('Unauthorized'); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - describe('authentication succeeds', () => { - it(`returns user data`, async () => { - authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(204); - expect(response.payload).toBeUndefined(); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('prefers `token` authentication provider if it is enabled', async () => { - authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.isProviderTypeEnabled.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: { type: '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 deleted file mode 100644 index ccc6a8df24d6e2c..000000000000000 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 { schema } from '@kbn/config-schema'; -import { wrapIntoCustomErrorResponse } from '../../errors'; -import { createLicensedRouteHandler } from '../licensed_route_handler'; -import { RouteDefinitionParams } from '..'; - -/** - * Defines routes required for Basic/Token authentication. - */ -export function defineBasicRoutes({ router, authc, config }: RouteDefinitionParams) { - router.post( - { - path: '/internal/security/login', - validate: { - body: schema.object({ - username: schema.string({ minLength: 1 }), - password: schema.string({ minLength: 1 }), - }), - }, - options: { authRequired: false }, - }, - createLicensedRouteHandler(async (context, request, response) => { - // We should prefer `token` over `basic` if possible. - const loginAttempt = { - provider: { type: authc.isProviderTypeEnabled('token') ? 'token' : 'basic' }, - value: request.body, - }; - - try { - const authenticationResult = await authc.login(request, loginAttempt); - if (!authenticationResult.succeeded()) { - return response.unauthorized({ body: authenticationResult.error }); - } - - return response.noContent(); - } catch (error) { - return response.customError(wrapIntoCustomErrorResponse(error)); - } - }) - ); -} diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index 5a0401e6320b467..8d800595d28ed60 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -181,12 +181,12 @@ describe('Common authentication routes', () => { }); }); - describe('login_with', () => { + describe('login', () => { let routeHandler: RequestHandler; let routeConfig: RouteConfig; beforeEach(() => { const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find( - ([{ path }]) => path === '/internal/security/login_with' + ([{ path }]) => path === '/internal/security/login' )!; routeConfig = acsRouteConfig; @@ -226,6 +226,39 @@ describe('Common authentication routes', () => { currentURL: '', }); + for (const [providerType, providerName] of [ + ['basic', 'basic1'], + ['token', 'token1'], + ]) { + expect( + bodyValidator.validate({ + providerType, + providerName, + currentURL: '', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toEqual({ + providerType, + providerName, + currentURL: '', + params: { username: 'some-user', password: 'some-password' }, + }); + + expect( + bodyValidator.validate({ + providerType, + providerName, + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toEqual({ + providerType, + providerName, + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }); + } + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( `"[providerType]: expected value of type [string] but got [undefined]"` ); @@ -250,6 +283,123 @@ describe('Common authentication routes', () => { UnknownArg: 'arg', }) ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + + expect(() => + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"[params]: a value wasn't expected to be present"`); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: 'some-user' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: '', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: 'some-user', password: '' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: 'some-user' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: '', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: 'some-user', password: '' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: value has length [0] but it must have a minimum length of [1]."` + ); }); it('returns 500 if login throws unhandled exception.', async () => { @@ -378,10 +528,10 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'saml1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', value: { type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/some-url', - redirectURLFragment: '#/app/nav', + redirectURL: '/mock-server-basepath/some-url#/app/nav', }, }); }); @@ -406,13 +556,66 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'oidc1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', value: { type: OIDCLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/some-url', + redirectURL: '/mock-server-basepath/some-url#/app/nav', }, }); }); + it('correctly performs Basic login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'basic', + providerName: 'basic1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + params: { username: 'some-user', password: 'some-password' }, + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'basic1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', + value: { username: 'some-user', password: 'some-password' }, + }); + }); + + it('correctly performs Token login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'token', + providerName: 'token1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + params: { username: 'some-user', password: 'some-password' }, + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'token1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', + value: { username: 'some-user', password: 'some-password' }, + }); + }); + it('correctly performs generic login.', async () => { authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); @@ -433,6 +636,7 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'some-name' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', }); }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index ad38a158af2b93c..a37f20c9ef82c29 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { parseNext } from '../../../common/parse_next'; -import { canRedirectRequest, OIDCLogin, SAMLLogin } from '../../authentication'; -import { wrapIntoCustomErrorResponse } from '../../errors'; -import { createLicensedRouteHandler } from '../licensed_route_handler'; import { + canRedirectRequest, + OIDCLogin, + SAMLLogin, + BasicAuthenticationProvider, OIDCAuthenticationProvider, SAMLAuthenticationProvider, -} from '../../authentication/providers'; + TokenAuthenticationProvider, +} from '../../authentication'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; import { RouteDefinitionParams } from '..'; /** @@ -83,19 +87,29 @@ export function defineCommonRoutes({ ); } - function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { - const [redirectURLPath] = redirectURL.split('#'); - const redirectURLFragment = - redirectURL.length > redirectURLPath.length - ? redirectURL.substring(redirectURLPath.length) - : ''; + const basicParamsSchema = schema.object({ + username: schema.string({ minLength: 1 }), + password: schema.string({ minLength: 1 }), + }); + function getLoginAttemptForProviderType( + providerType: T, + redirectURL: string, + params: T extends 'basic' | 'token' ? TypeOf : {} + ) { if (providerType === SAMLAuthenticationProvider.type) { - return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + return { type: SAMLLogin.LoginInitiatedByUser, redirectURL }; } if (providerType === OIDCAuthenticationProvider.type) { - return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + return { type: OIDCLogin.LoginInitiatedByUser, redirectURL }; + } + + if ( + providerType === BasicAuthenticationProvider.type || + providerType === TokenAuthenticationProvider.type + ) { + return params; } return undefined; @@ -103,25 +117,35 @@ export function defineCommonRoutes({ router.post( { - path: '/internal/security/login_with', + path: '/internal/security/login', validate: { body: schema.object({ providerType: schema.string(), providerName: schema.string(), currentURL: schema.string(), + params: schema.conditional( + schema.siblingRef('providerType'), + schema.oneOf([ + schema.literal(BasicAuthenticationProvider.type), + schema.literal(TokenAuthenticationProvider.type), + ]), + basicParamsSchema, + schema.never() + ), }), }, options: { authRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { - const { providerType, providerName, currentURL } = request.body; + const { providerType, providerName, currentURL, params } = request.body; logger.info(`Logging in with provider "${providerName}" (${providerType})`); const redirectURL = parseNext(currentURL, basePath.serverBasePath); try { const authenticationResult = await authc.login(request, { provider: { name: providerName }, - value: getLoginAttemptForProviderType(providerType, redirectURL), + redirectURL, + value: getLoginAttemptForProviderType(providerType, redirectURL, params), }); if (authenticationResult.redirected() || authenticationResult.succeeded()) { diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index d09f65525f44e0d..6527fd02205843e 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -4,21 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defineSessionRoutes } from './session'; import { defineSAMLRoutes } from './saml'; -import { defineBasicRoutes } from './basic'; import { defineCommonRoutes } from './common'; import { defineOIDCRoutes } from './oidc'; import { RouteDefinitionParams } from '..'; export function defineAuthenticationRoutes(params: RouteDefinitionParams) { - defineSessionRoutes(params); defineCommonRoutes(params); - if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { - defineBasicRoutes(params); - } - if (params.authc.isProviderTypeEnabled('saml')) { defineSAMLRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index ce7516c2c9d880f..58ec7f559bc2863 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -12,79 +12,7 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineSAMLRoutes({ - router, - httpResources, - logger, - authc, - basePath, -}: RouteDefinitionParams) { - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - // We're also preventing `favicon.ico` request since it can cause new SAML handshake. - return response.renderHtml({ - body: ` - - Kibana SAML Login - - - `, - }); - } - ); - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment.js', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - return response.renderJs({ - body: ` - window.location.replace( - '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) - ); - `, - }); - } - ); - - router.get( - { - path: '/internal/security/saml/start', - validate: { - query: schema.object({ redirectURLFragment: schema.string() }), - }, - options: { authRequired: false }, - }, - async (context, request, response) => { - try { - const authenticationResult = await authc.login(request, { - provider: { type: SAMLAuthenticationProvider.type }, - value: { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: request.query.redirectURLFragment, - }, - }); - - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - if (authenticationResult.redirected()) { - return response.redirected({ headers: { location: authenticationResult.redirectURL! } }); - } - - return response.unauthorized(); - } catch (err) { - logger.error(err); - return response.internalError(); - } - } - ); - +export function defineSAMLRoutes({ router, logger, authc }: RouteDefinitionParams) { router.post( { path: '/api/security/saml/callback', diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 24de2af5e9703c7..b4698708f86fe0a 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -14,6 +14,7 @@ import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; +import { sessionMock } from '../session_management/session.mock'; export const routeDefinitionParamsMock = { create: (config: Record = {}) => ({ @@ -31,5 +32,6 @@ export const routeDefinitionParamsMock = { httpResources: httpResourcesMock.createRegistrar(), getFeatures: jest.fn(), getFeatureUsageService: jest.fn(), + session: sessionMock.create(), }), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 82c0186898d383f..a3f046ae4f9e600 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -6,8 +6,8 @@ import { Feature } from '../../../features/server'; import { - CoreSetup, HttpResources, + IBasePath, ILegacyClusterClient, IRouter, Logger, @@ -23,21 +23,24 @@ import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; +import { defineSessionManagementRoutes } from './session_management'; import { defineViewRoutes } from './views'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { Session } from '../session_management'; /** * Describes parameters used to define HTTP routes. */ export interface RouteDefinitionParams { router: IRouter; - basePath: CoreSetup['http']['basePath']; + basePath: IBasePath; httpResources: HttpResources; logger: Logger; clusterClient: ILegacyClusterClient; config: ConfigType; authc: Authentication; authz: AuthorizationServiceSetup; + session: PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; @@ -46,6 +49,7 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); + defineSessionManagementRoutes(params); defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); diff --git a/x-pack/plugins/security/server/routes/authentication/session.ts b/x-pack/plugins/security/server/routes/session_management/extend.ts similarity index 57% rename from x-pack/plugins/security/server/routes/authentication/session.ts rename to x-pack/plugins/security/server/routes/session_management/extend.ts index cdebc19d7cf8dbf..722636aa9934a0f 100644 --- a/x-pack/plugins/security/server/routes/authentication/session.ts +++ b/x-pack/plugins/security/server/routes/session_management/extend.ts @@ -7,26 +7,9 @@ import { RouteDefinitionParams } from '..'; /** - * Defines routes required for all authentication realms. + * Defines routes required for the session extension. */ -export function defineSessionRoutes({ router, logger, authc, basePath }: RouteDefinitionParams) { - router.get( - { - path: '/internal/security/session', - validate: false, - }, - async (_context, request, response) => { - try { - const sessionInfo = await authc.getSessionInfo(request); - // This is an authenticated request, so sessionInfo will always be non-null. - return response.ok({ body: sessionInfo! }); - } catch (err) { - logger.error(`Error retrieving user session: ${err.message}`); - return response.internalError(); - } - } - ); - +export function defineSessionExtendRoutes({ router, basePath }: RouteDefinitionParams) { router.post( { path: '/internal/security/session', diff --git a/x-pack/plugins/security/server/routes/session_management/index.ts b/x-pack/plugins/security/server/routes/session_management/index.ts new file mode 100644 index 000000000000000..aeed027972ed02f --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { defineSessionExtendRoutes } from './extend'; +import { defineSessionInfoRoutes } from './info'; +import { RouteDefinitionParams } from '..'; + +export function defineSessionManagementRoutes(params: RouteDefinitionParams) { + defineSessionInfoRoutes(params); + defineSessionExtendRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts new file mode 100644 index 000000000000000..0c6d173b80d77a1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -0,0 +1,43 @@ +/* + * 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 { SessionInfo } from '../../../common/types'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the session info. + */ +export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/session', + validate: false, + options: { authRequired: 'optional' }, + }, + async (_context, request, response) => { + try { + const sessionValue = await session.get(request); + return response.ok( + sessionValue + ? { + body: { + // We can't rely on the client's system clock, so in addition to returning expiration timestamps, we also return + // the current server time -- that way the client can calculate the relative time to expiration. + now: Date.now(), + idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, + lifespanExpiration: sessionValue.lifespanExpiration, + provider: sessionValue.provider, + } as SessionInfo, + } + : {} + ); + } catch (err) { + logger.error(`Error retrieving user session: ${err.message}`); + return response.internalError(); + } + } + ); +} diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index 21c7fc134043711..4cbc9d81b872ca0 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -17,15 +17,18 @@ import { ScopeableRequest, } from '../../../../../../src/core/server'; import { Authentication, AuthenticationResult } from '../../authentication'; +import { Session } from '../../session_management'; import { defineChangeUserPasswordRoutes } from './change_password'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Change password', () => { let router: jest.Mocked; let authc: jest.Mocked; + let session: jest.Mocked>; let mockClusterClient: jest.Mocked; let mockScopedClusterClient: jest.Mocked; let routeHandler: RequestHandler; @@ -46,15 +49,11 @@ describe('Change password', () => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; authc = routeParamsMock.authc; + session = routeParamsMock.session; - authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ username: 'user' })); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser(mockAuthenticatedUser())); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.getSessionInfo.mockResolvedValue({ - now: Date.now(), - idleTimeoutExpiration: null, - lifespanExpiration: null, - provider: { type: 'basic', name: 'basic' }, - }); + session.get.mockResolvedValue(sessionMock.createSessionValue()); mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockClusterClient = routeParamsMock.clusterClient; @@ -220,7 +219,7 @@ describe('Change password', () => { }); it('successfully changes own password but does not re-login if current session does not exist.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); expect(response.status).toBe(204); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index e915cd8759ff105..66dc25295f29bb8 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -16,6 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ authc, + session, router, clusterClient, }: RouteDefinitionParams) { @@ -37,7 +38,7 @@ export function defineChangeUserPasswordRoutes({ const currentUser = authc.getCurrentUser(request); const isUserChangingOwnPassword = currentUser && currentUser.username === username && canUserChangePassword(currentUser); - const currentSession = isUserChangingOwnPassword ? await authc.getSessionInfo(request) : null; + const currentSession = isUserChangingOwnPassword ? await session.get(request) : null; // If user is changing their own password they should provide a proof of knowledge their // current password via sending it in `Authorization: Basic base64(username:current password)` @@ -80,6 +81,9 @@ export function defineChangeUserPasswordRoutes({ // session and in such cases we shouldn't create a new one. if (isUserChangingOwnPassword && currentSession) { try { + // Even though user is still the same, password change warrants a new session. + await session.clear(request); + const authenticationResult = await authc.login(request, { provider: { name: currentUser!.authentication_provider }, value: { username, password: newPassword }, diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts index 3d616575b841314..e1a3ff9b040b707 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts @@ -16,24 +16,25 @@ import { import { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing'; import { AuthenticationProvider } from '../../../common/types'; import { ConfigType } from '../../config'; +import { Session } from '../../session_management'; import { defineAccessAgreementRoutes } from './access_agreement'; import { httpResourcesMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; -import { Authentication } from '../../authentication'; describe('Access agreement view routes', () => { let httpResources: jest.Mocked; let router: jest.Mocked; let config: ConfigType; - let authc: jest.Mocked; + let session: jest.Mocked>; let license: jest.Mocked; let mockContext: RequestHandlerContext; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; httpResources = routeParamsMock.httpResources; - authc = routeParamsMock.authc; + session = routeParamsMock.session; config = routeParamsMock.config; license = routeParamsMock.license; @@ -125,7 +126,7 @@ describe('Access agreement view routes', () => { it('returns empty `accessAgreement` if session info is not available.', async () => { const request = httpServerMock.createKibanaRequest(); - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ options: { body: { accessAgreement: '' } }, @@ -159,12 +160,9 @@ describe('Access agreement view routes', () => { ]; for (const [sessionProvider, expectedAccessAgreement] of cases) { - authc.getSessionInfo.mockResolvedValue({ - now: Date.now(), - idleTimeoutExpiration: null, - lifespanExpiration: null, - provider: sessionProvider, - }); + session.get.mockResolvedValue( + sessionMock.createSessionValue({ provider: sessionProvider }) + ); await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ options: { body: { accessAgreement: expectedAccessAgreement } }, diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts index 49e1ff42a28a2a5..80a1c2a20cf599d 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts @@ -12,7 +12,7 @@ import { RouteDefinitionParams } from '..'; * Defines routes required for the Access Agreement view. */ export function defineAccessAgreementRoutes({ - authc, + session, httpResources, license, config, @@ -46,12 +46,12 @@ export function defineAccessAgreementRoutes({ // authenticated with the help of HTTP authentication), that means we should safely check if // we have it and can get a corresponding configuration. try { - const session = await authc.getSessionInfo(request); + const sessionValue = await session.get(request); const accessAgreement = - (session && + (sessionValue && config.authc.providers[ - session.provider.type as keyof ConfigType['authc']['providers'] - ]?.[session.provider.name]?.accessAgreement?.message) || + sessionValue.provider.type as keyof ConfigType['authc']['providers'] + ]?.[sessionValue.provider.name]?.accessAgreement?.message) || ''; return response.ok({ body: { accessAgreement } }); diff --git a/x-pack/plugins/security/server/routes/views/capture_url.ts b/x-pack/plugins/security/server/routes/views/capture_url.ts new file mode 100644 index 000000000000000..690c68dcd59aaff --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/capture_url.ts @@ -0,0 +1,28 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the Capture URL view. + */ +export function defineCaptureURLRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register( + { + path: '/internal/security/capture-url', + validate: { + query: schema.object({ + providerType: schema.string({ minLength: 1 }), + providerName: schema.string({ minLength: 1 }), + next: schema.maybe(schema.string()), + }), + }, + options: { authRequired: false }, + }, + (context, request, response) => response.renderAnonymousCoreApp() + ); +} diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 0c0117dec539092..fa2088a80b18339 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -25,6 +25,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -51,6 +52,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -78,6 +80,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -105,6 +108,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index b9de58d47fe4077..64d288dfc7c7d65 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -10,6 +10,7 @@ import { defineLoggedOutRoutes } from './logged_out'; import { defineLoginRoutes } from './login'; import { defineLogoutRoutes } from './logout'; import { defineOverwrittenSessionRoutes } from './overwritten_session'; +import { defineCaptureURLRoutes } from './capture_url'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { @@ -26,4 +27,5 @@ export function defineViewRoutes(params: RouteDefinitionParams) { defineLoggedOutRoutes(params); defineLogoutRoutes(params); defineOverwrittenSessionRoutes(params); + defineCaptureURLRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts index 7cb73c49f9cbc86..a8bb71a29a902e2 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -5,19 +5,20 @@ */ import { HttpResourcesRequestHandler, RouteConfig } from '../../../../../../src/core/server'; -import { Authentication } from '../../authentication'; +import { Session } from '../../session_management'; import { defineLoggedOutRoutes } from './logged_out'; import { httpServerMock, httpResourcesMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; describe('LoggedOut view routes', () => { - let authc: jest.Mocked; + let session: jest.Mocked>; let routeHandler: HttpResourcesRequestHandler; let routeConfig: RouteConfig; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - authc = routeParamsMock.authc; + session = routeParamsMock.session; defineLoggedOutRoutes(routeParamsMock); @@ -38,12 +39,7 @@ describe('LoggedOut view routes', () => { }); it('redirects user to the root page if they have a session already.', async () => { - authc.getSessionInfo.mockResolvedValue({ - provider: { type: 'basic', name: 'basic' }, - now: 0, - idleTimeoutExpiration: null, - lifespanExpiration: null, - }); + session.get.mockResolvedValue(sessionMock.createSessionValue()); const request = httpServerMock.createKibanaRequest(); @@ -54,17 +50,17 @@ describe('LoggedOut view routes', () => { headers: { location: '/mock-server-basepath/' }, }); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(session.get).toHaveBeenCalledWith(request); }); it('renders view if user does not have an active session.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); const request = httpServerMock.createKibanaRequest(); const responseFactory = httpResourcesMock.createResponseFactory(); await routeHandler({} as any, request, responseFactory); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(session.get).toHaveBeenCalledWith(request); expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index 43c2f01b1b53d41..b35154e6a0f2a4d 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -17,7 +17,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineLoggedOutRoutes({ logger, - authc, + session, httpResources, basePath, }: RouteDefinitionParams) { @@ -30,7 +30,7 @@ export function defineLoggedOutRoutes({ async (context, request, response) => { // Authentication flow isn't triggered automatically for this route, so we should explicitly // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) !== null; + const isUserAlreadyLoggedIn = (await session.get(request)) !== null; if (isUserAlreadyLoggedIn) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ diff --git a/x-pack/plugins/security/server/session_management/index.mock.ts b/x-pack/plugins/security/server/session_management/index.mock.ts new file mode 100644 index 000000000000000..e1b766131a3b108 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/index.mock.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { sessionMock } from './session.mock'; diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts new file mode 100644 index 000000000000000..ee7ed914947a048 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export { Session, SessionValue } from './session'; +export { + SessionManagementServiceSetup, + SessionManagementService, +} from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts new file mode 100644 index 000000000000000..0e43c659c3fae59 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -0,0 +1,47 @@ +/* + * 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 { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { Session, SessionValue } from './session'; +import { SessionIndexValue } from './session_index'; + +const createSessionIndexValue = ( + sessionValue: Partial = {} +): SessionIndexValue => ({ + sid: 'some-long-sid', + username_hash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + path: '/', + content: 'some-encrypted-content', + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + ...sessionValue, +}); + +export const sessionMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + extend: jest.fn(), + clear: jest.fn(), + }), + + createSessionValue: (sessionValue: Partial = {}): SessionValue => ({ + sid: 'some-long-sid', + username: mockAuthenticatedUser().username, + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + path: '/', + state: undefined, + metadata: { index: createSessionIndexValue(sessionValue.metadata?.index) }, + ...sessionValue, + }), + + createSessionIndexValue, +}; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts new file mode 100644 index 000000000000000..adff731e359b949 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -0,0 +1,358 @@ +/* + * 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. + */ + +describe('Session', () => { + describe('#get', () => { + it('returns `null` if session cookie does not exist', () => {}); + + /* + +function getMockOptions({ + session, + providers, + http = {}, + selector, +}: { + session?: AuthenticatorOptions['config']['session']; + providers?: Record | string[]; + http?: Partial; + selector?: AuthenticatorOptions['config']['authc']['selector']; +} = {}) { + return { + auditLogger: securityAuditLoggerMock.create(), + getCurrentUser: jest.fn(), + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createSetupContract().basePath, + license: licenseMock.create(), + loggers: loggingServiceMock.create(), + config: createConfig( + ConfigSchema.validate({ session, authc: { selector, providers, http } }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), + session: sessionMock.create(), + }; +} + +describe('getSessionInfo()', () => { + let sessionMockInstance: jest.Mocked>; + let getSessionInfo: (r: KibanaRequest) => Promise; + beforeEach(async () => { + sessionMockInstance = sessionMock.create(); + jest.requireMock('./session').Session.mockImplementation(() => sessionMockInstance); + + getSessionInfo = (await setupAuthentication(mockSetupAuthenticationParams)).getSessionInfo; + }); + + it('returns current session info if session exists.', async () => { + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + const mockInfo = { + now: currentDate, + idleTimeoutExpiration: currentDate + 60000, + lifespanExpiration: currentDate + 120000, + provider: { type: 'basic', name: 'basic1' }, + }; + + sessionMockInstance.get.mockResolvedValue({ + provider: mockInfo.provider, + idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, + lifespanExpiration: mockInfo.lifespanExpiration, + state: { authorization: 'Basic xxx' }, + path: mockSetupAuthenticationParams.http.basePath.serverBasePath, + }); + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + await expect(getSessionInfo(httpServerMock.createKibanaRequest())).resolves.toEqual(mockInfo); + }); + + it('returns `null` if session does not exist.', async () => { + sessionMockInstance.get.mockResolvedValue(null); + + await expect(getSessionInfo(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + }); +}); + +it('properly initializes session storage and registers auth handler', async () => { + const config = { + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }; + + await setupAuthentication(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + + expect( + mockSetupAuthenticationParams.http.createCookieSessionStorageFactory + ).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.createCookieSessionStorageFactory).toHaveBeenCalledWith( + { + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + validate: expect.any(Function), + } + ); +}); + +it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); +}); + +it('clears session if it belongs to a different provider.', async () => { + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = httpServerMock.createKibanaRequest(); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, credentials, null); + + expect(mockOptions.session.set).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalled(); +}); + +it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = httpServerMock.createKibanaRequest(); + + // Re-configure authenticator with `token` provider that uses the name of `basic`. + const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user)); + jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({ + type: 'token', + login: loginMock, + getHTTPAuthenticationScheme: jest.fn(), + })); + mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + await expect( + authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(loginMock).toHaveBeenCalledWith(request, credentials, null); + + expect(mockOptions.session.set).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalled(); +}); + +it('properly extends session expiration if it is defined.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + + // Create new authenticator with non-null session `idleTimeout`. + mockOptions = getMockOptions({ + session: { + idleTimeout: duration(3600 * 24), + lifespan: null, + }, + providers: { basic: { basic1: { order: 0 } } }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + idleTimeoutExpiration: currentDate + 3600 * 24, + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); +}); + +it('does not extend session lifespan expiration.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + const hr = 1000 * 60 * 60; + + // Create new authenticator with non-null session `idleTimeout` and `lifespan`. + mockOptions = getMockOptions({ + session: { + idleTimeout: duration(hr * 2), + lifespan: duration(hr * 8), + }, + providers: { basic: { basic1: { order: 0 } } }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + // this session was created 6.5 hrs ago (and has 1.5 hrs left in its lifespan) + // it was last extended 1 hour ago, which means it will expire in 1 hour + idleTimeoutExpiration: currentDate + hr * 1, + lifespanExpiration: currentDate + hr * 1.5, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + idleTimeoutExpiration: currentDate + hr * 2, + lifespanExpiration: currentDate + hr * 1.5, + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); +}); + +describe('conditionally updates the session lifespan expiration', () => { + const hr = 1000 * 60 * 60; + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + + async function createAndUpdateSession( + lifespan: Duration | null, + oldExpiration: number | null, + newExpiration: number | null + ) { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + mockOptions = getMockOptions({ + session: { + idleTimeout: null, + lifespan, + }, + providers: { basic: { basic1: { order: 0 } } }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + idleTimeoutExpiration: null, + lifespanExpiration: oldExpiration, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + } + + it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { + await createAndUpdateSession(duration(hr * 8), 1234, 1234); + }); + it('does not change a null lifespan expiration when configured to null value.', async () => { + await createAndUpdateSession(null, null, null); + }); + it('does change a non-null lifespan expiration when configured to null value.', async () => { + await createAndUpdateSession(null, 1234, null); + }); + it('does change a null lifespan expiration when configured to non-null value', async () => { + await createAndUpdateSession(duration(hr * 8), null, currentDate + hr * 8); + }); +}); + +it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); +}); + +it('clears session if it belongs to not configured provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + const state = { authorization: 'Bearer xxx' }; + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + state, + provider: { type: 'token', name: 'token1' }, + }); + + await expect(authenticator.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalled(); +}); +*/ + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts new file mode 100644 index 000000000000000..856037f29430959 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -0,0 +1,450 @@ +/* + * 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 nodeCrypto, { Crypto } from '@elastic/node-crypto'; +import { promisify } from 'util'; +import { randomBytes, createHash } from 'crypto'; +import { Duration } from 'moment'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; +import { SecurityAuditLogger } from '../audit'; +import { ConfigType } from '../config'; +import { SessionIndex, SessionIndexValue } from './session_index'; +import { SessionCookie } from './session_cookie'; + +/** + * The shape of the value that represents user's session information. + */ +export interface SessionValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Username this session belongs. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + username?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * Session value that is fed to the authentication provider. The shape is unknown upfront and + * entirely determined by the authentication provider that owns the current session. + */ + state: unknown; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; + + /** + * Additional information about the session value. + */ + metadata: { index: SessionIndexValue }; +} + +export interface SessionOptions { + auditLogger: SecurityAuditLogger; + serverBasePath: string; + logger: Logger; + sessionIndex: SessionIndex; + sessionCookie: SessionCookie; + config: Pick; +} + +interface SessionValueContentToEncrypt { + username?: string; + state: unknown; +} + +export class Session { + /** + * Type/name mappings of the currently configured authentication providers. + */ + readonly #providers: Map; + + /** + * Session timeout in ms. If `null` session will stay active until the browser is closed. + */ + readonly #idleTimeout: Duration | null; + + /** + * Timeout after which idle timeout property is updated in the index. It's two times longer than + * configured idle timeout since index updates are costly and we want to minimize them. + */ + readonly #idleIndexUpdateTimeout: number | null; + + /** + * Session max lifespan in ms. If `null` session may live indefinitely. + */ + readonly #lifespan: Duration | null; + + /** + * Used to encrypt and decrypt portion of the session value using configured encryption key. + */ + readonly #crypto: Crypto; + + /** + * Promise-based version of the NodeJS native `randomBytes`. + */ + readonly #randomBytes = promisify(randomBytes); + + /** + * Options used to create Session. + */ + readonly #options: Readonly; + + constructor(options: Readonly) { + this.#options = options; + this.#providers = new Map( + this.#options.config.authc.sortedProviders.map(({ name, type }) => [name, type]) + ); + this.#crypto = nodeCrypto({ encryptionKey: this.#options.config.encryptionKey }); + this.#idleTimeout = this.#options.config.session.idleTimeout; + this.#lifespan = this.#options.config.session.lifespan; + this.#idleIndexUpdateTimeout = this.#options.config.session.idleTimeout + ? this.#options.config.session.idleTimeout.asMilliseconds() * 2 + : null; + } + + /** + * Extracts session value for the specified request. Under the hood it can clear session if it is + * invalid, created by the legacy versions of Kibana or belongs to the provider that is no longer + * available. + * @param request Request instance to get session value for. + */ + async get(request: KibanaRequest) { + const sessionCookieValue = await this.#options.sessionCookie.get(request); + if (!sessionCookieValue) { + return null; + } + + if ( + (sessionCookieValue.idleTimeoutExpiration && + sessionCookieValue.idleTimeoutExpiration < Date.now()) || + (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < Date.now()) + ) { + this.#options.logger.debug('Session has expired and will be invalidated.'); + await this.clear(request); + return null; + } + + const sessionIndexValue = await this.#options.sessionIndex.get(sessionCookieValue.sid); + if (!sessionIndexValue) { + this.#options.logger.debug( + 'Session value is not available in the index, session cookie will be invalidated.' + ); + await this.clear(request); + return null; + } + + // If we detect that for some reason we have a session stored for the provider that is not + // available anymore (e.g. when user was logged in with one provider, but then configuration has + // changed and that provider is no longer available), then we should clear session entirely. + if (this.#providers.get(sessionIndexValue.provider.name) !== sessionIndexValue.provider.type) { + this.#options.logger.warn( + `Session was created for "${sessionIndexValue.provider.name}/${sessionIndexValue.provider.type}" provider that is no longer configured or has a different type. Session will be invalidated.` + ); + await this.clear(request); + return null; + } + + try { + return { + ...(await this.decryptSessionValue(sessionIndexValue, sessionCookieValue.aad)), + // Unlike session index, session cookie contains the most up to date idle timeout expiration. + idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration, + }; + } catch (err) { + await this.clear(request); + return null; + } + } + + /** + * Creates new session document in the session index encrypting sensitive state. + * @param request Request instance to create session value for. + * @param sessionValue Session value parameters. + */ + async create( + request: KibanaRequest, + sessionValue: Readonly< + Omit< + SessionValue, + 'sid' | 'idleTimeoutExpiration' | 'lifespanExpiration' | 'path' | 'metadata' + > + > + ) { + // Do we want to partition these calls or merge in a single 512 call instead? Technically 512 + // will be faster, and we'll occupy just one thread. + const [sid, aad] = await Promise.all([ + this.#randomBytes(256).then((sidBuffer) => sidBuffer.toString('base64')), + this.#randomBytes(256).then((aadBuffer) => aadBuffer.toString('base64')), + ]); + + const sessionExpirationInfo = this.calculateExpiry(); + const path = this.#options.serverBasePath; + const createdSessionValue = { ...sessionValue, ...sessionExpirationInfo, sid, path }; + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + const sessionIndexValue = await this.#options.sessionIndex.create( + await this.encryptSessionValue(createdSessionValue, aad) + ); + await this.#options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad, path }); + + this.#options.logger.debug('Successfully created new session.'); + + return { ...createdSessionValue, metadata: { index: sessionIndexValue } } as Readonly< + SessionValue + >; + } + + /** + * Creates or updates session value for the specified request. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async update(request: KibanaRequest, sessionValue: Readonly) { + const sessionCookieValue = await this.#options.sessionCookie.get(request); + if (!sessionCookieValue) { + throw new Error('Session cannot be update since it doesnt exist.'); + } + + const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); + const { metadata, ...sessionValueToUpdate } = sessionValue; + const updatedSessionValue = { + ...sessionValueToUpdate, + ...sessionExpirationInfo, + path: this.#options.serverBasePath, + }; + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + const sessionIndexValue = await this.#options.sessionIndex.update({ + ...sessionValue.metadata.index, + ...(await this.encryptSessionValue(updatedSessionValue, sessionCookieValue.aad)), + }); + + // Session may be already invalidated by another concurrent request, in this case we should + // clear cookie for the request as well. + if (sessionIndexValue === null) { + this.#options.logger.warn('Session cannot be updated as it has been invalidated already.'); + await this.#options.sessionCookie.clear(request); + return null; + } + + await this.#options.sessionCookie.set(request, { + ...sessionCookieValue, + ...sessionExpirationInfo, + }); + + this.#options.logger.debug('Successfully updated existing session.'); + + return { ...updatedSessionValue, metadata: { index: sessionIndexValue } } as Readonly< + SessionValue + >; + } + + /** + * Extends existing session. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async extend(request: KibanaRequest, sessionValue: Readonly) { + const sessionCookieValue = await this.#options.sessionCookie.get(request); + if (!sessionCookieValue) { + throw new Error('Session cannot be extended since it doesnt exist.'); + } + + // We calculate actual expiration values based on the information extracted from the portion of + // the session value that is stored in the cookie since it always contains the most recent value. + const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); + if ( + sessionExpirationInfo.idleTimeoutExpiration === sessionValue.idleTimeoutExpiration && + sessionExpirationInfo.lifespanExpiration === sessionValue.lifespanExpiration + ) { + return sessionValue; + } + + // Session index updates are costly and should be minimized, but these are the cases when we + // should update session index: + let updateSessionIndex = false; + if ( + (sessionExpirationInfo.idleTimeoutExpiration === null && + sessionValue.idleTimeoutExpiration !== null) || + (sessionExpirationInfo.idleTimeoutExpiration !== null && + sessionValue.idleTimeoutExpiration === null) + ) { + // 1. If idle timeout wasn't configured when session was initially created and is configured + // now or vice versa. + this.#options.logger.debug( + 'Session idle timeout configuration has changed, session index will be updated.' + ); + updateSessionIndex = true; + } else if ( + (sessionExpirationInfo.lifespanExpiration === null && + sessionValue.lifespanExpiration !== null) || + (sessionExpirationInfo.lifespanExpiration !== null && + sessionValue.lifespanExpiration === null) + ) { + // 2. If lifespan wasn't configured when session was initially created and is configured now + // or vice versa. + this.#options.logger.debug( + 'Session lifespan configuration has changed, session index will be updated.' + ); + updateSessionIndex = true; + } else if ( + this.#idleIndexUpdateTimeout !== null && + this.#idleIndexUpdateTimeout < + sessionExpirationInfo.idleTimeoutExpiration! - sessionValue.idleTimeoutExpiration! + ) { + // 3. If idle timeout was updated a while ago. + this.#options.logger.debug( + 'Session idle timeout stored in the index is too old and will be updated.' + ); + updateSessionIndex = true; + } + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + if (updateSessionIndex) { + const sessionIndexValue = await this.#options.sessionIndex.update({ + ...sessionValue.metadata.index, + ...sessionExpirationInfo, + }); + + // Session may be already invalidated by another concurrent request, in this case we should + // clear cookie for the request as well. + if (sessionIndexValue === null) { + this.#options.logger.warn('Session cannot be extended as it has been invalidated already.'); + await this.#options.sessionCookie.clear(request); + return null; + } + + sessionValue.metadata.index = sessionIndexValue; + } + + await this.#options.sessionCookie.set(request, { + ...sessionCookieValue, + ...sessionExpirationInfo, + }); + + this.#options.logger.debug('Successfully extended existing session.'); + + return { ...sessionValue, ...sessionExpirationInfo } as Readonly; + } + + /** + * Clears session value for the specified request. + * @param request Request instance to clear session value for. + */ + async clear(request: KibanaRequest) { + const sessionCookieValue = await this.#options.sessionCookie.get(request); + if (!sessionCookieValue) { + return null; + } + + await Promise.all([ + this.#options.sessionCookie.clear(request), + this.#options.sessionIndex.clear(sessionCookieValue.sid), + ]); + + this.#options.logger.debug('Successfully invalidated existing session.'); + } + + /** + * Encrypts session value content and converts to a value stored in the session index. + * @param sessionValue Session value. + * @param aad Additional authenticated data (AAD) used for encryption. + */ + private async encryptSessionValue( + sessionValue: Readonly>, + aad: string + ) { + // Extract values that shouldn't be directly included into session index value. + const { username, state, ...sessionIndexValue } = sessionValue; + + try { + const encryptedContent = await this.#crypto.encrypt( + JSON.stringify({ username, state } as SessionValueContentToEncrypt), + aad + ); + return { + ...sessionIndexValue, + username_hash: username && createHash('sha3-256').update(username).digest('hex'), + content: encryptedContent, + }; + } catch (err) { + this.#options.logger.error(`Failed to encrypt session value: ${err.message}`); + throw err; + } + } + + /** + * Decrypts session value content from the value stored in the session index. + * @param sessionIndexValue Session value retrieved from the session index. + * @param aad Additional authenticated data (AAD) used for decryption. + */ + private async decryptSessionValue(sessionIndexValue: Readonly, aad: string) { + // Extract values that are specific to session index value. + const { username_hash, content, ...sessionValue } = sessionIndexValue; + + try { + const decryptedContent = JSON.parse( + (await this.#crypto.decrypt(content, aad)) as string + ) as SessionValueContentToEncrypt; + return { + ...sessionValue, + ...decryptedContent, + metadata: { index: sessionIndexValue }, + } as Readonly; + } catch (err) { + this.#options.logger.error(`Failed to decrypt session value: ${err.message}`); + throw err; + } + } + + private calculateExpiry( + currentLifespanExpiration?: number | null + ): { idleTimeoutExpiration: number | null; lifespanExpiration: number | null } { + const now = Date.now(); + // if we are renewing an existing session, use its `lifespanExpiration` -- otherwise, set this value + // based on the configured server `lifespan`. + // note, if the server had a `lifespan` set and then removes it, remove `lifespanExpiration` on renewed sessions + // also, if the server did not have a `lifespan` set and then adds it, add `lifespanExpiration` on renewed sessions + const lifespanExpiration = + currentLifespanExpiration && this.#lifespan + ? currentLifespanExpiration + : this.#lifespan && now + this.#lifespan.asMilliseconds(); + const idleTimeoutExpiration = this.#idleTimeout && now + this.#idleTimeout.asMilliseconds(); + + return { idleTimeoutExpiration, lifespanExpiration }; + } +} diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts new file mode 100644 index 000000000000000..4ee6f5767b0caac --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -0,0 +1,133 @@ +/* + * 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 { + HttpServiceSetup, + KibanaRequest, + Logger, + SessionStorageFactory, +} from '../../../../../src/core/server'; +import { ConfigType } from '../config'; + +/** + * Represents shape of the session value stored in the cookie. + */ +export interface SessionCookieValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Unique random value used as Additional authenticated data (AAD) while encrypting/decrypting + * sensitive or PII session content stored in the Elasticsearch index. This value is only stored + * in the user cookie. + */ + aad: string; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; +} + +export interface SessionCookieOptions { + logger: Logger; + serverBasePath: string; + createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; + config: Pick; +} + +export class SessionCookie { + /** + * Promise containing initialized cookie session storage factory. + */ + readonly #cookieSessionValueStorage: Promise>>; + + /** + * Options used to create Session Cookie. + */ + readonly #options: Readonly; + + constructor(options: Readonly) { + this.#options = options; + this.#cookieSessionValueStorage = this.#options.createCookieSessionStorageFactory({ + encryptionKey: options.config.encryptionKey, + isSecure: options.config.secureCookies, + name: options.config.cookieName, + sameSite: options.config.sameSiteCookies, + validate: (sessionValue: SessionCookieValue | SessionCookieValue[]) => { + // ensure that this cookie was created with the current Kibana configuration + const invalidSessionValue = (Array.isArray(sessionValue) + ? sessionValue + : [sessionValue] + ).find((sess) => sess.path !== undefined && sess.path !== this.#options.serverBasePath); + + if (invalidSessionValue) { + options.logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); + return { isValid: false, path: invalidSessionValue.path }; + } + + return { isValid: true }; + }, + }); + } + + /** + * Extracts session value for the specified request. + * @param request Request instance to get session value for. + */ + async get(request: KibanaRequest) { + const sessionStorage = (await this.#cookieSessionValueStorage).asScoped(request); + const sessionValue = await sessionStorage.get(); + + // If we detect that cookie session value is in incompatible format, then we should clear such + // cookie. + if (sessionValue && !SessionCookie.isSupportedSessionValue(sessionValue)) { + sessionStorage.clear(); + return null; + } + + return sessionValue; + } + + /** + * Creates or updates session value for the specified request. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async set(request: KibanaRequest, sessionValue: Readonly) { + (await this.#cookieSessionValueStorage).asScoped(request).set(sessionValue); + } + + /** + * Clears session value for the specified request. + * @param request Request instance to clear session value for. + */ + async clear(request: KibanaRequest) { + (await this.#cookieSessionValueStorage).asScoped(request).clear(); + } + + /** + * Determines if session value was created by the current Kibana version. Previous versions had a different session value format. + * @param sessionValue The session value to check. + */ + private static isSupportedSessionValue(sessionValue: any): sessionValue is SessionCookieValue { + return typeof sessionValue?.sid === 'string' && typeof sessionValue?.aad === 'string'; + } +} diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts new file mode 100644 index 000000000000000..b51506dcc289ccf --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -0,0 +1,378 @@ +/* + * 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 { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; +import { ConfigType } from '../config'; + +export interface SessionIndexOptions { + clusterClient: ILegacyClusterClient; + serverBasePath: string; + config: Pick; + logger: Logger; +} + +/** + * Version of the current session index template. + */ +const SESSION_INDEX_TEMPLATE_VERSION = 1; + +/** + * Alias of the Elasticsearch index that is used to store user session information. + */ +const SESSION_INDEX_ALIAS = '.kibana_security_session'; + +/** + * Name of the Elasticsearch index that is used to store user session information. + */ +const SESSION_INDEX_NAME = `${SESSION_INDEX_ALIAS}_${SESSION_INDEX_TEMPLATE_VERSION}`; + +/** + * Index template that is used for the current version of the session index. + */ +const SESSION_INDEX_TEMPLATE = { + name: `${SESSION_INDEX_ALIAS}_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`, + version: SESSION_INDEX_TEMPLATE_VERSION, + template: { + index_patterns: SESSION_INDEX_NAME, + order: 1000, + settings: { + index: { + number_of_shards: 1, + number_of_replicas: 0, + auto_expand_replicas: '0-1', + priority: 1000, + refresh_interval: '1s', + hidden: true, + }, + }, + mappings: { + dynamic: 'strict', + properties: { + username_hash: { type: 'keyword' }, + provider: { properties: { name: { type: 'keyword' }, type: { type: 'keyword' } } }, + path: { type: 'keyword' }, + idleTimeoutExpiration: { type: 'date' }, + lifespanExpiration: { type: 'date' }, + accessAgreementAcknowledged: { type: 'boolean' }, + content: { type: 'binary' }, + }, + }, + aliases: { [SESSION_INDEX_ALIAS]: {} }, + }, +}; + +/** + * Represents shape of the session value stored in the index. + */ +export interface SessionIndexValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Hash of the username. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + username_hash?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; + + /** + * Content of the session value represented as an encrypted JSON string. + */ + content: string; + + /** + * Additional index specific information about the session value. + */ + metadata: SessionIndexValueMetadata; +} + +/** + * Additional index specific information about the session value. + */ +interface SessionIndexValueMetadata { + /** + * Primary term of the last modification of the document. + */ + primaryTerm: number; + + /** + * Sequence number of the last modification of the document. + */ + sequenceNumber: number; +} + +export class SessionIndex { + /** + * Timeout after which session with the expired idle timeout _may_ be removed from the index + * during regular cleanup routine. It's intentionally larger than `idleIndexUpdateTimeout` + * configured in `Session` to be sure that the session value may be safely cleaned up. + */ + readonly #idleIndexCleanupTimeout: number | null; + + /** + * Options used to create Session index. + */ + readonly #options: Readonly; + + /** + * Indicates whether session index is initialized. + */ + private isInitialized = false; + + constructor(options: Readonly) { + this.#options = options; + this.#idleIndexCleanupTimeout = this.#options.config.session.idleTimeout + ? this.#options.config.session.idleTimeout.asMilliseconds() * 3 + : null; + } + + /** + * Retrieves session value with the specified ID from the index. If session value isn't found + * `null` will be returned. + * @param sid Session ID. + */ + async get(sid: string) { + try { + const response = await this.#options.clusterClient.callAsInternalUser('get', { + id: sid, + ignore: [404], + index: SESSION_INDEX_ALIAS, + }); + + const docNotFound = response.found === false; + const indexNotFound = response.status === 404; + if (docNotFound || indexNotFound) { + this.#options.logger.debug('Cannot find session value with the specified ID.'); + return null; + } + + return { + sid, + ...response._source, + metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, + } as Readonly; + } catch (err) { + this.#options.logger.error(`Failed to retrieve session value: ${err.message}`); + throw err; + } + } + + /** + * Creates a new document for the specified session value. + * @param sessionValue Session index value. + */ + async create(sessionValue: Readonly>) { + if (!this.isInitialized) { + this.#options.logger.error( + 'Attempted to create a new session before session index is initialized.' + ); + throw new Error('Session index is not initialized'); + } + + const { sid, ...sessionValueToStore } = sessionValue; + try { + const { + _primary_term: primaryTerm, + _seq_no: sequenceNumber, + } = await this.#options.clusterClient.callAsInternalUser('create', { + id: sid, + // We cannot control whether index is created automatically during this operation or not. + // But we can reduce probability of getting into a weird state when session is being created + // while session index is missing for some reason. This way we'll recreate index with a + // proper name and alias. But this will only work if we still have a proper index template. + index: SESSION_INDEX_NAME, + body: sessionValueToStore, + refresh: 'wait_for', + }); + + return { ...sessionValue, metadata: { primaryTerm, sequenceNumber } } as SessionIndexValue; + } catch (err) { + this.#options.logger.error(`Failed to create session value: ${err.message}`); + throw err; + } + } + + /** + * Re-indexes updated session value. + * @param sessionValue Session index value. + */ + async update(sessionValue: Readonly) { + const { sid, metadata, ...sessionValueToStore } = sessionValue; + try { + const response = await this.#options.clusterClient.callAsInternalUser('index', { + id: sid, + index: SESSION_INDEX_ALIAS, + body: sessionValueToStore, + ifSeqNo: metadata.sequenceNumber, + ifPrimaryTerm: metadata.primaryTerm, + refresh: 'wait_for', + ignore: [409], + }); + + // We don't want to override changes that were made after we fetched session value or + // re-create it if has been deleted already. If we detect such a case we discard changes and + // return latest copy of the session value instead or `null` if doesn't exist anymore. + const sessionIndexValueUpdateConflict = response.status === 409; + if (sessionIndexValueUpdateConflict) { + this.#options.logger.debug( + 'Cannot update session value due to conflict, session either do not exist or was already updated.' + ); + return await this.get(sid); + } + + return { + ...sessionValue, + metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, + } as SessionIndexValue; + } catch (err) { + this.#options.logger.error(`Failed to update session value: ${err.message}`); + throw err; + } + } + + /** + * Clears session value with the specified ID. May trigger a removal of other outdated session + * values. + * @param sid Session ID to clear. + */ + async clear(sid: string) { + try { + const now = Date.now(); + + // Always try to delete session with the specified ID and with expired lifespan (even if it's + // not configured right now). + // QUESTION: CAN WE SAY THAT ALL TENANTS SHOULD HAVE THE SAME SESSION TIMEOUTS? + const deleteQueries: object[] = [ + { term: { _id: sid } }, + { range: { lifespanExpiration: { lte: now } } }, + // { bool: { must_not: { term: { path: this.#options.serverBasePath } } } }, + ]; + + // If lifespan is configured we should remove sessions that were created without it if any. + if (this.#options.config.session.lifespan) { + deleteQueries.push({ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }); + } + + // If idle timeout is configured we should delete all sessions without specified idle timeout + // or if that session hasn't been updated for a while meaning that session is expired. + if (this.#idleIndexCleanupTimeout) { + deleteQueries.push( + { range: { idleTimeoutExpiration: { lte: now - this.#idleIndexCleanupTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } } + ); + } else { + // Otherwise just delete all expired sessions that were previously created with the idle + // timeout if any. + deleteQueries.push({ range: { idleTimeoutExpiration: { lte: now } } }); + } + + await this.#options.clusterClient.callAsInternalUser('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { conflicts: 'proceed', query: { bool: { should: deleteQueries } } }, + }); + } catch (err) { + this.#options.logger.error(`Failed to clear session value: ${err.message}`); + throw err; + } + } + + /** + * Initializes index that is used to store session values. + */ + async initialize() { + this.isInitialized = false; + + // Check if required index template exists. + let indexTemplateExists = false; + try { + indexTemplateExists = await this.#options.clusterClient.callAsInternalUser( + 'indices.existsTemplate', + { name: SESSION_INDEX_TEMPLATE.name } + ); + } catch (err) { + this.#options.logger.error( + `Failed to check if session index template exists: ${err.message}` + ); + throw err; + } + + // Create index template if it doesn't exist. + if (indexTemplateExists) { + this.#options.logger.debug('Session index template already exists.'); + } else { + try { + await this.#options.clusterClient.callAsInternalUser('indices.putTemplate', { + name: SESSION_INDEX_TEMPLATE.name, + body: SESSION_INDEX_TEMPLATE.template, + }); + this.#options.logger.debug('Successfully created session index template.'); + } catch (err) { + this.#options.logger.error(`Failed to create session index template: ${err.message}`); + throw err; + } + } + + // Check if required index exists. We cannot be sure that automatic creation of indices is + // always enabled, so we create session index explicitly. + let indexExists = false; + try { + indexExists = await this.#options.clusterClient.callAsInternalUser('indices.exists', { + index: SESSION_INDEX_NAME, + }); + } catch (err) { + this.#options.logger.error(`Failed to check if session index exists: ${err.message}`); + throw err; + } + + // Create index if it doesn't exist. + if (indexExists) { + this.#options.logger.debug('Session index already exists.'); + } else { + try { + await this.#options.clusterClient.callAsInternalUser('indices.create', { + index: SESSION_INDEX_NAME, + }); + this.#options.logger.debug('Successfully created session index.'); + } catch (err) { + this.#options.logger.error(`Failed to create session index: ${err.message}`); + throw err; + } + } + + this.isInitialized = true; + } +} diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts new file mode 100644 index 000000000000000..f9baa03fc5bb724 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -0,0 +1,93 @@ +/* + * 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 { Observable, Subscription } from 'rxjs'; +import { HttpServiceSetup, ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { SecurityAuditLogger } from '../audit'; +import { ConfigType } from '../config'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { SessionCookie } from './session_cookie'; +import { SessionIndex } from './session_index'; +import { Session } from './session'; + +export interface SessionManagementServiceSetupParams { + readonly auditLogger: SecurityAuditLogger; + readonly http: Pick; + readonly config: ConfigType; + readonly clusterClient: ILegacyClusterClient; +} + +export interface SessionManagementServiceStartParams { + readonly online$: Observable; +} + +export interface SessionManagementServiceSetup { + readonly session: Session; +} + +/** + * Service responsible for the user session management. + */ +export class SessionManagementService { + readonly #logger: Logger; + #statusSubscription?: Subscription; + #sessionIndex!: SessionIndex; + + constructor(logger: Logger) { + this.#logger = logger; + } + + setup({ + auditLogger, + config, + clusterClient, + http, + }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { + const serverBasePath = http.basePath.serverBasePath || '/'; + + const sessionCookie = new SessionCookie({ + config, + createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, + serverBasePath, + logger: this.#logger.get('cookie'), + }); + + this.#sessionIndex = new SessionIndex({ + config, + clusterClient, + serverBasePath, + logger: this.#logger.get('index'), + }); + + return { + session: new Session({ + auditLogger, + serverBasePath, + logger: this.#logger, + sessionCookie, + sessionIndex: this.#sessionIndex, + config, + }), + }; + } + + start({ online$ }: SessionManagementServiceStartParams) { + this.#statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { + try { + await this.#sessionIndex.initialize(); + } catch (err) { + scheduleRetry(); + } + }); + } + + stop() { + if (this.#statusSubscription !== undefined) { + this.#statusSubscription.unsubscribe(); + this.#statusSubscription = undefined; + } + } +} diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 4479c8d9d1fbe69..f46d2c65c565fda 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -78,8 +78,13 @@ const loginViaEnvironmentCredentials = () => { // programmatically authenticate without interacting with the Kibana login page cy.request({ body: { - username: Cypress.env(ELASTICSEARCH_USERNAME), - password: Cypress.env(ELASTICSEARCH_PASSWORD), + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username: Cypress.env(ELASTICSEARCH_USERNAME), + password: Cypress.env(ELASTICSEARCH_PASSWORD), + }, }, headers: { 'kbn-xsrf': 'cypress-creds-via-env' }, method: 'POST', @@ -104,8 +109,13 @@ const loginViaConfig = () => { // programmatically authenticate without interacting with the Kibana login page cy.request({ body: { - username: config.elasticsearch.username, - password: config.elasticsearch.password, + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username: config.elasticsearch.username, + password: config.elasticsearch.password, + }, }, headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, method: 'POST', diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 29be6d826c1bc40..c52a88806fdc824 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,7 +9,8 @@ const alwaysImportedTests = [ require.resolve('../test/security_solution_endpoint/config.ts'), require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), - require.resolve('../test/functional/config_security_trial.ts'), + require.resolve('../test/security_functional/login_selector.config.ts'), + require.resolve('../test/security_functional/saml.config.ts'), ]; const onlyNotInCoverageTests = [ require.resolve('../test/api_integration/config_security_basic.ts'), diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index 284330cf0fc9d50..70eddc9aee4d8eb 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -39,19 +39,34 @@ export default function ({ getService }) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: wrongUsername, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: wrongUsername, password: wrongPassword }, + }) .expect(401); await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: wrongPassword }, + }) .expect(401); await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: wrongUsername, password: validPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: wrongUsername, password: validPassword }, + }) .expect(401); }); @@ -59,8 +74,13 @@ export default function ({ getService }) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); const cookies = loginResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -134,8 +154,13 @@ export default function ({ getService }) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); sessionCookie = request.cookie(loginResponse.headers['set-cookie'][0]); }); diff --git a/x-pack/test/api_integration/apis/security/change_password.ts b/x-pack/test/api_integration/apis/security/change_password.ts index 217c23959669047..48892b23470929e 100644 --- a/x-pack/test/api_integration/apis/security/change_password.ts +++ b/x-pack/test/api_integration/apis/security/change_password.ts @@ -22,8 +22,13 @@ export default function ({ getService }: FtrProviderContext) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) + .expect(200); sessionCookie = cookie(loginResponse.headers['set-cookie'][0])!; }); @@ -44,22 +49,37 @@ export default function ({ getService }: FtrProviderContext) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: wrongPassword }, + }) .expect(401); // Let's check that we can't login with the password we were supposed to set. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: newPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: newPassword }, + }) .expect(401); // And can login with the current password. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) + .expect(200); }); it('should allow password change if current password is correct', async () => { @@ -85,7 +105,12 @@ export default function ({ getService }: FtrProviderContext) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) .expect(401); // But new cookie should be valid. @@ -99,8 +124,13 @@ export default function ({ getService }: FtrProviderContext) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: newPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: newPassword }, + }) + .expect(200); }); }); } diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/api_integration/apis/security/session.ts index ddd36f33225585e..64ecdda20130103 100644 --- a/x-pack/test/api_integration/apis/security/session.ts +++ b/x-pack/test/api_integration/apis/security/session.ts @@ -45,8 +45,13 @@ export default function ({ getService }: FtrProviderContext) { await supertestWithoutAuth .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200) .then(saveCookie); }); diff --git a/x-pack/test/api_integration/services/legacy_es.js b/x-pack/test/api_integration/services/legacy_es.js index 0ea061365aca2e6..04b991151034a95 100644 --- a/x-pack/test/api_integration/services/legacy_es.js +++ b/x-pack/test/api_integration/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin as securityEsClientPlugin } from '../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin as securityEsClientPlugin } from '../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; import { elasticsearchJsPlugin as indexManagementEsClientPlugin } from '../../../plugins/index_management/server/client/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { DEFAULT_API_VERSION } from '../../../../src/core/server/elasticsearch/elasticsearch_config'; diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index 8d2e575fad31336..38a8697e05252ba 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -58,8 +58,13 @@ export default function ({ getService }: FtrProviderContext) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 54b37fe52cc56cb..439e553b17a86a8 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -76,7 +76,7 @@ export default function ({ getService }: FtrProviderContext) { it('should allow access to login selector with intermediate authentication cookie', async () => { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ providerType: 'saml', providerName: 'saml1', currentURL: 'https://kibana.com/' }) @@ -176,19 +176,24 @@ export default function ({ getService }: FtrProviderContext) { }); it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { - const basicAuthenticationResponse = await supertest - .post('/internal/security/login') - .ca(CA_CERT) - .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + for (const providerName of ['saml1', 'saml2']) { + const basicAuthenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); - const basicSessionCookie = request.cookie( - basicAuthenticationResponse.headers['set-cookie'][0] - )!; - await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); + const basicSessionCookie = request.cookie( + basicAuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); - for (const providerName of ['saml1', 'saml2']) { const authenticationResponse = await supertest .post('/api/security/saml/callback') .ca(CA_CERT) @@ -200,8 +205,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(302); - // It should be `/overwritten_session` instead of `/` once it's generalized. - expect(authenticationResponse.headers.location).to.be('/'); + expect(authenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2F' + ); const cookies = authenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -235,8 +241,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(302); - // It should be `/overwritten_session` instead of `/` once it's generalized. - expect(saml2AuthenticationResponse.headers.location).to.be('/'); + expect(saml2AuthenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2F' + ); const saml2SessionCookie = request.cookie( saml2AuthenticationResponse.headers['set-cookie'][0] @@ -271,9 +278,9 @@ export default function ({ getService }: FtrProviderContext) { .send({ RelayState: '/app/kibana#/dashboards' }) .expect(302); - // It should be `/overwritten_session` with `?next='/app/kibana#/dashboards'` instead of just - // `'/app/kibana#/dashboards'` once it's generalized. - expect(saml2AuthenticationResponse.headers.location).to.be('/app/kibana#/dashboards'); + expect(saml2AuthenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2Fapp%2Fkibana%23%2Fdashboards' + ); const saml2SessionCookie = request.cookie( saml2AuthenticationResponse.headers['set-cookie'][0] @@ -288,7 +295,7 @@ export default function ({ getService }: FtrProviderContext) { it('should fail for IdP initiated login if intermediate session with other SAML provider exists', async () => { // First start authentication flow with `saml1`. const saml1HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -320,7 +327,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login with any configured realm', async () => { for (const providerName of ['saml1', 'saml2']) { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -366,7 +373,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { // First start authentication flow with `saml1`. const saml1HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -386,7 +393,7 @@ export default function ({ getService }: FtrProviderContext) { // And now try to login with `saml2`. const saml2HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .set('Cookie', saml1HandshakeCookie.cookieString()) @@ -428,7 +435,7 @@ export default function ({ getService }: FtrProviderContext) { describe('Kerberos', () => { it('should be able to log in from Login Selector', async () => { const spnegoResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -442,7 +449,7 @@ export default function ({ getService }: FtrProviderContext) { expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .set('Authorization', `Negotiate ${getSPNEGOToken()}`) @@ -470,7 +477,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { const spnegoResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -485,7 +492,7 @@ export default function ({ getService }: FtrProviderContext) { expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -547,7 +554,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login', async () => { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -612,7 +619,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in from Login Selector', async () => { const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts similarity index 73% rename from x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts index 0ef60bb9298267e..0acae074f129f20 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', function () { this.tags('ciGroup6'); loadTestFile(require.resolve('./oidc_auth')); diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts similarity index 73% rename from x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts index f91eb492afe2474..18dfdcffef363e8 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts @@ -5,41 +5,85 @@ */ import expect from '@kbn/expect'; -import request from 'request'; +import request, { Cookie } from 'request'; import url from 'url'; -import { getStateAndNonce } from '../../fixtures/oidc_tools'; import { delay } from 'bluebird'; +import { getStateAndNonce } from '../../fixtures/oidc_tools'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService }) { +export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); describe('OpenID Connect authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); }); + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', request.cookie(cookies[0])!.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); + expect(user.authentication_provider).to.eql('basic'); + }); + describe('initiating handshake', () => { - it('should properly set cookie, return all parameters and redirect user', async () => { + it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .expect(302); + expect(handshakeResponse.headers['set-cookie']).to.be(undefined); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc' + ); + }); + + it('should properly set cookie, return all parameters and redirect user', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); + const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); @@ -57,7 +101,7 @@ export default function ({ getService }) { const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); @@ -67,9 +111,9 @@ export default function ({ getService }) { handshakeResponse.headers.location, true /* parseQueryString */ ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); @@ -80,10 +124,17 @@ export default function ({ getService }) { it('should not allow access to the API with the handshake cookie', async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') @@ -102,16 +153,23 @@ export default function ({ getService }) { }); describe('finishing handshake', () => { - let stateAndNonce; - let handshakeCookie; + let stateAndNonce: { state: string; nonce: string }; + let handshakeCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -144,13 +202,13 @@ export default function ({ getService }) { // User should be redirected to the URL that initiated handshake. expect(oidcAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' + '/abc/xyz/handshake?one=two%20three#/workpad' ); const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const sessionCookie = request.cookie(cookies[0]); + const sessionCookie = request.cookie(cookies[0])!; expect(sessionCookie.key).to.be('sid'); expect(sessionCookie.value).to.not.be.empty(); expect(sessionCookie.path).to.be('/'); @@ -182,7 +240,7 @@ export default function ({ getService }) { const handshakeResponse = await supertest .get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') .expect(302); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens @@ -200,7 +258,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const sessionCookie = request.cookie(cookies[0]); + const sessionCookie = request.cookie(cookies[0])!; expect(sessionCookie.key).to.be('sid'); expect(sessionCookie.value).to.not.be.empty(); expect(sessionCookie.path).to.be('/'); @@ -228,14 +286,23 @@ export default function ({ getService }) { }); describe('API access with active session', () => { - let stateAndNonce; - let sessionCookie; + let stateAndNonce: { state: string; nonce: string }; + let sessionCookie: Cookie; beforeEach(async () => { - const handshakeResponse = await supertest.get('/abc/xyz').expect(302); + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -249,7 +316,7 @@ export default function ({ getService }) { .set('Cookie', sessionCookie.cookieString()) .expect(302); - sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0]); + sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0])!; }); it('should extend cookie on every successful non-system API call', async () => { @@ -260,7 +327,7 @@ export default function ({ getService }) { .expect(200); expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); - const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0]); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0])!; expect(sessionCookieOne.value).to.not.be.empty(); expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); @@ -272,7 +339,7 @@ export default function ({ getService }) { .expect(200); expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); - const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0]); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0])!; expect(sessionCookieTwo.value).to.not.be.empty(); expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); @@ -302,15 +369,22 @@ export default function ({ getService }) { }); describe('logging out', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -327,7 +401,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); it('should redirect to home page if session cookie is not provided', async () => { @@ -346,7 +420,7 @@ export default function ({ getService }) { const cookies = logoutResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const logoutCookie = request.cookie(cookies[0]); + const logoutCookie = request.cookie(cookies[0])!; expect(logoutCookie.key).to.be('sid'); expect(logoutCookie.value).to.be.empty(); expect(logoutCookie.path).to.be('/'); @@ -355,23 +429,16 @@ export default function ({ getService }) { const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */); expect( - redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`) + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`) ).to.be(true); expect(redirectURL.query.id_token_hint).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old - // session cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should reject AJAX requests', async () => { @@ -391,15 +458,22 @@ export default function ({ getService }) { }); describe('API access with expired access token.', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -416,10 +490,10 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); - const expectNewSessionCookie = (cookie) => { + const expectNewSessionCookie = (cookie: Cookie) => { expect(cookie.key).to.be('sid'); expect(cookie.value).to.not.be.empty(); expect(cookie.path).to.be('/'); @@ -445,7 +519,7 @@ export default function ({ getService }) { const firstResponseCookies = firstResponse.headers['set-cookie']; expect(firstResponseCookies).to.have.length(1); - const firstNewCookie = request.cookie(firstResponseCookies[0]); + const firstNewCookie = request.cookie(firstResponseCookies[0])!; expectNewSessionCookie(firstNewCookie); // Request with old cookie should reuse the same refresh token if within 60 seconds. @@ -459,7 +533,7 @@ export default function ({ getService }) { const secondResponseCookies = secondResponse.headers['set-cookie']; expect(secondResponseCookies).to.have.length(1); - const secondNewCookie = request.cookie(secondResponseCookies[0]); + const secondNewCookie = request.cookie(secondResponseCookies[0])!; expectNewSessionCookie(secondNewCookie); expect(firstNewCookie.value).not.to.eql(secondNewCookie.value); @@ -481,15 +555,22 @@ export default function ({ getService }) { }); describe('API access with missing access token document.', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -506,7 +587,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); it('should properly set cookie and start new OIDC handshake', async function () { @@ -521,26 +602,30 @@ export default function ({ getService }) { expect(esResponse).to.have.property('deleted').greaterThan(0); const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(302); + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index f35c72ea135c9e8..39a8426fc30f0a4 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -23,11 +23,18 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); }); it('should return an HTML page that will parse URL fragment', async () => { @@ -118,7 +125,7 @@ export default function ({ getService }: FtrProviderContext) { // User should be redirected to the URL that initiated handshake. expect(oidcAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' + '/abc/xyz/handshake?one=two%20three#/workpad' ); const cookies = oidcAuthenticationResponse.headers['set-cookie']; diff --git a/x-pack/test/oidc_api_integration/config.ts b/x-pack/test/oidc_api_integration/config.ts index 7a0d786e2013019..08aa0a6d9c0dde4 100644 --- a/x-pack/test/oidc_api_integration/config.ts +++ b/x-pack/test/oidc_api_integration/config.ts @@ -49,7 +49,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), `--plugin-path=${plugin}`, - '--xpack.security.authc.providers=["oidc"]', + `--xpack.security.authc.providers=${JSON.stringify(['oidc', 'basic'])}`, '--xpack.security.authc.oidc.realm="oidc1"', ], }, diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index 33e608d0b18f191..664fdb9fba67afd 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -70,8 +70,13 @@ export default function ({ getService }: FtrProviderContext) { .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -147,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { .get('/internal/security/me') .ca(CA_CERT) .pfx(SECOND_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200, { username: 'second_client', diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index ab33ecc1eb87a0c..d78f4da63ab5b42 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -9,7 +9,6 @@ import url from 'url'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; import request, { Cookie } from 'request'; -import { JSDOM } from 'jsdom'; import { getLogoutRequest, getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml_tools'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -72,8 +71,13 @@ export default function ({ getService }: FtrProviderContext) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -89,88 +93,28 @@ export default function ({ getService }: FtrProviderContext) { expect(user.authentication_provider).to.eql('basic'); }); - describe('capture URL fragment', () => { + describe('initiating handshake', () => { it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .expect(302); - // The cookie should capture current path. - const cookies = handshakeResponse.headers['set-cookie']; - expect(cookies).to.have.length(1); - - const handshakeCookie = request.cookie(cookies[0])!; - expect(handshakeCookie.key).to.be('sid'); - expect(handshakeCookie.value).to.not.be.empty(); - expect(handshakeCookie.path).to.be('/'); - expect(handshakeCookie.httpOnly).to.be(true); - + expect(handshakeResponse.headers['set-cookie']).to.be(undefined); expect(handshakeResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml' ); }); - it('should return an HTML page that will extract URL fragment', async () => { - const response = await supertest - .get('/internal/security/saml/capture-url-fragment') - .expect(200); - - const kibanaBaseURL = url.format({ ...config.get('servers.kibana'), auth: false }); - const dom = new JSDOM(response.text, { - url: kibanaBaseURL, - runScripts: 'dangerously', - resources: 'usable', - beforeParse(window) { - // JSDOM doesn't support changing of `window.location` and throws an exception if script - // tries to do that and we have to workaround this behaviour. We also need to wait until our - // script is loaded and executed, __isScriptExecuted__ is used exactly for that. - (window as Record).__isScriptExecuted__ = new Promise((resolve) => { - Object.defineProperty(window, 'location', { - value: { - hash: '#/workpad', - href: `${kibanaBaseURL}/internal/security/saml/capture-url-fragment#/workpad`, - replace(newLocation: string) { - this.href = newLocation; - resolve(); - }, - }, - }); - }); - }, - }); - - await (dom.window as Record).__isScriptExecuted__; - - // Check that proxy page is returned with proper headers. - expect(response.headers['content-type']).to.be('text/html; charset=utf-8'); - expect(response.headers['cache-control']).to.be( - 'private, no-cache, no-store, must-revalidate' - ); - expect(response.headers['content-security-policy']).to.be( - `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'` - ); - - // Check that script that forwards URL fragment worked correctly. - expect(dom.window.location.href).to.be( - '/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad' - ); - }); - }); - - describe('initiating handshake', () => { - const initiateHandshakeURL = `/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`; - - let captureURLCookie: Cookie; - beforeEach(async () => { - const response = await supertest.get('/abc/xyz/handshake?one=two three').expect(302); - captureURLCookie = request.cookie(response.headers['set-cookie'][0])!; - }); - it('should properly set cookie and redirect user to IdP', async () => { const handshakeResponse = await supertest - .get(initiateHandshakeURL) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -181,19 +125,21 @@ export default function ({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); expect(redirectURL.query.SAMLRequest).to.not.be.empty(); }); - it('should not allow access to the API', async () => { + it('should not allow access to the API with the handshake cookie', async () => { const handshakeResponse = await supertest - .get(initiateHandshakeURL) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; await supertest @@ -218,18 +164,19 @@ export default function ({ getService }: FtrProviderContext) { let samlRequestId: string; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get(`/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); }); it('should fail if SAML response is not complemented with handshake cookie', async () => { @@ -356,20 +303,19 @@ export default function ({ getService }: FtrProviderContext) { let idpSessionIndex: string; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); idpSessionIndex = String(randomness.naturalNumber()); const samlAuthenticationResponse = await supertest @@ -407,19 +353,12 @@ export default function ({ getService }: FtrProviderContext) { expect(redirectURL.href!.startsWith(`https://elastic.co/slo/saml`)).to.be(true); expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old - // session cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should redirect to home page if session cookie is not provided', async () => { @@ -465,19 +404,12 @@ export default function ({ getService }: FtrProviderContext) { expect(redirectURL.href!.startsWith(`https://elastic.co/slo/saml`)).to.be(true); expect(redirectURL.query.SAMLResponse).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old session - // cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should invalidate access token on IdP initiated logout even if there is no Kibana session', async () => { @@ -515,20 +447,19 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async function () { this.timeout(40000); - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -616,20 +547,19 @@ export default function ({ getService }: FtrProviderContext) { let sessionCookie: Cookie; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -651,7 +581,7 @@ export default function ({ getService }: FtrProviderContext) { expect(esResponse).to.have.property('deleted').greaterThan(0); }); - it('should properly set cookie and start new SAML handshake', async () => { + it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .set('Cookie', sessionCookie.cookieString()) @@ -662,15 +592,42 @@ export default function ({ getService }: FtrProviderContext) { const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); - expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.value).to.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); + expect(handshakeCookie.maxAge).to.be(0); expect(handshakeResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml' ); }); + it('should properly set cookie and redirect user to IdP', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); + + const cookies = handshakeResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const handshakeCookie = request.cookie(cookies[0])!; + expect(handshakeCookie.key).to.be('sid'); + expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.path).to.be('/'); + expect(handshakeCookie.httpOnly).to.be(true); + + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); + expect(redirectURL.query.SAMLRequest).to.not.be.empty(); + }); + it('should start new SAML handshake even if multiple concurrent requests try to refresh access token', async () => { // Issue 5 concurrent requests with a cookie that contains access/refresh token pair without // a corresponding document in Elasticsearch. @@ -711,20 +668,18 @@ export default function ({ getService }: FtrProviderContext) { ]; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -762,18 +717,14 @@ export default function ({ getService }: FtrProviderContext) { expect(newSessionCookie.value).to.not.be.empty(); expect(newSessionCookie.value).to.not.equal(existingSessionCookie.value); - // Tokens from old cookie are invalidated. - const rejectedResponse = await supertest + // Same user, same provider - session ID hasn't changed and cookie should still be valid. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) - .expect(400); - expect(rejectedResponse.body).to.have.property( - 'message', - 'Both access and refresh tokens are expired.' - ); + .expect(200); - // Only tokens from new session are valid. + // New session cookie is also valid. await checkSessionCookie(newSessionCookie); }); @@ -789,7 +740,7 @@ export default function ({ getService }: FtrProviderContext) { .expect(302); expect(samlAuthenticationResponse.headers.location).to.be( - '/security/overwritten_session' + '/security/overwritten_session?next=%2F' ); const newSessionCookie = request.cookie( @@ -798,99 +749,17 @@ export default function ({ getService }: FtrProviderContext) { expect(newSessionCookie.value).to.not.be.empty(); expect(newSessionCookie.value).to.not.equal(existingSessionCookie.value); - // Tokens from old cookie are invalidated. - const rejectedResponse = await supertest + // New username - old session is invalidated and session ID in the cookie no longer valid. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) - .expect(400); - expect(rejectedResponse.body).to.have.property( - 'message', - 'Both access and refresh tokens are expired.' - ); + .expect(401); // Only tokens from new session are valid. await checkSessionCookie(newSessionCookie, newUsername); }); } }); - - describe('handshake with very long URL path or fragment', () => { - it('should not try to capture URL fragment if path is too big already', async () => { - // 1. Initiate SAML handshake. - const handshakeResponse = await supertest - .get(`/abc/xyz/${'handshake'.repeat(10)}?one=two three`) - .expect(302); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - - expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); - expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - - // 2. Finish SAML handshake - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', handshakeCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) }) - .expect(302); - - // User should be redirected to the root URL since we couldn't even save URL path. - expect(samlAuthenticationResponse.headers.location).to.be('/'); - - await checkSessionCookie( - request.cookie(samlAuthenticationResponse.headers['set-cookie'][0])! - ); - }); - - it('should capture only URL path if URL fragment is too big', async () => { - // 1. Capture current path - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - - expect(captureURLResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' - ); - - // 2. Initiate SAML handshake. - const handshakeResponse = await supertest - .get(`/internal/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); - - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - - expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); - expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - - // 3. Finish SAML handshake - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', handshakeCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) }) - .expect(302); - - // User should be redirected to the URL path that initiated SAML handshake. - expect(samlAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' - ); - - await checkSessionCookie( - request.cookie(samlAuthenticationResponse.headers['set-cookie'][0])! - ); - }); - }); }); } diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts b/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts index 5777aa3f423f0cd..b9253a04932445f 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts @@ -43,4 +43,15 @@ export function initRoutes(core: CoreSetup) { return response.renderJs({ body: 'document.getElementById("loginForm").submit();' }); } ); + + core.http.resources.register( + { + path: '/saml_provider/logout', + validate: false, + options: { authRequired: false }, + }, + async (context, request, response) => { + return response.redirected({ headers: { location: '/logout?SAMLResponse=something' } }); + } + ); } diff --git a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js index 9267fa312ed0654..c8bf1810daafeed 100644 --- a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js +++ b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); diff --git a/x-pack/test/security_functional/ftr_provider_context.d.ts b/x-pack/test/security_functional/ftr_provider_context.d.ts new file mode 100644 index 000000000000000..d8f146e4c6f6bc0 --- /dev/null +++ b/x-pack/test/security_functional/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * 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 { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from '../functional/page_objects'; +import { services } from '../functional/services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional/config_security_trial.ts b/x-pack/test/security_functional/login_selector.config.ts similarity index 93% rename from x-pack/test/functional/config_security_trial.ts rename to x-pack/test/security_functional/login_selector.config.ts index e34baef0be477dd..69517154f27454c 100644 --- a/x-pack/test/functional/config_security_trial.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -8,8 +8,8 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { services } from './services'; -import { pageObjects } from './page_objects'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/page_objects'; // the default export of config files must be a config provider // that returns an object with the projects config values @@ -26,7 +26,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); return { - testFiles: [resolve(__dirname, './apps/security/trial_license')], + testFiles: [resolve(__dirname, './tests/login_selector')], services, pageObjects, @@ -78,7 +78,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { screenshots: { directory: resolve(__dirname, 'screenshots') }, junit: { - reportName: 'Chrome X-Pack UI Functional Tests', + reportName: 'Chrome X-Pack Security Functional Tests', }, }; } diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts new file mode 100644 index 000000000000000..7f8bf944d0b1f07 --- /dev/null +++ b/x-pack/test/security_functional/saml.config.ts @@ -0,0 +1,78 @@ +/* + * 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. + */ + +/* eslint-disable import/no-default-export */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/page_objects'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); + const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); + const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + + return { + testFiles: [resolve(__dirname, './tests/saml')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'trial', + from: 'snapshot', + serverArgs: [ + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.realms.saml.saml1.order=0', + `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', + `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7', + ], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${samlIdPPlugin}`, + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + '--xpack.security.authc.selector.enabled=false', + '--xpack.security.authc.providers.saml.saml1.order=0', + '--xpack.security.authc.providers.saml.saml1.realm=saml1', + '--xpack.security.authc.providers.basic.basic1.order=1', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + apps: kibanaFunctionalConfig.get('apps'), + esArchiver: { directory: resolve(__dirname, 'es_archives') }, + screenshots: { directory: resolve(__dirname, 'screenshots') }, + + junit: { + reportName: 'Chrome X-Pack Security Functional Tests', + }, + }; +} diff --git a/x-pack/test/functional/apps/security/trial_license/login_selector.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts similarity index 94% rename from x-pack/test/functional/apps/security/trial_license/login_selector.ts rename to x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index e0b776cd123c17c..153387c52e5c3c5 100644 --- a/x-pack/test/functional/apps/security/trial_license/login_selector.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { parse } from 'url'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const PageObjects = getPageObjects(['security', 'common']); - describe('Login Selector', function () { + describe('Basic functionality', function () { this.tags('includeFirefox'); before(async () => { @@ -23,12 +23,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) .expect(200); - await esArchiver.load('empty_kibana'); + await esArchiver.load('../../functional/es_archives/empty_kibana'); await PageObjects.security.forceLogout(); }); after(async () => { - await esArchiver.unload('empty_kibana'); + await esArchiver.unload('../../functional/es_archives/empty_kibana'); }); beforeEach(async () => { diff --git a/x-pack/test/security_functional/tests/login_selector/index.ts b/x-pack/test/security_functional/tests/login_selector/index.ts new file mode 100644 index 000000000000000..0d1060fbf1f513e --- /dev/null +++ b/x-pack/test/security_functional/tests/login_selector/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security app - login selector', function () { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./basic_functionality')); + }); +} diff --git a/x-pack/test/functional/apps/security/trial_license/index.ts b/x-pack/test/security_functional/tests/saml/index.ts similarity index 65% rename from x-pack/test/functional/apps/security/trial_license/index.ts rename to x-pack/test/security_functional/tests/saml/index.ts index 99d600c1eafdae9..4b3d6a925bf764a 100644 --- a/x-pack/test/functional/apps/security/trial_license/index.ts +++ b/x-pack/test/security_functional/tests/saml/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('security app - trial license', function () { + describe('security app - SAML interactions', function () { this.tags('ciGroup4'); - loadTestFile(require.resolve('./login_selector')); + loadTestFile(require.resolve('./url_capture')); }); } diff --git a/x-pack/test/security_functional/tests/saml/url_capture.ts b/x-pack/test/security_functional/tests/saml/url_capture.ts new file mode 100644 index 000000000000000..5d47d80efadcb0d --- /dev/null +++ b/x-pack/test/security_functional/tests/saml/url_capture.ts @@ -0,0 +1,54 @@ +/* + * 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 expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common']); + + describe('URL capture', function () { + this.tags('includeFirefox'); + + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/saml1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) + .expect(200); + + await esArchiver.load('../../functional/es_archives/empty_kibana'); + }); + + after(async () => { + await esArchiver.unload('../../functional/es_archives/empty_kibana'); + }); + + afterEach(async () => { + await browser.get(PageObjects.common.getHostPort() + '/logout'); + await PageObjects.common.waitUntilUrlIncludes('logged_out'); + }); + + it('can login preserving original URL', async () => { + await browser.get( + PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + ); + + await find.byCssSelector( + '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + 20000 + ); + + // We need to make sure that both path and hash are respected. + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect(currentURL.hash).to.eql('#some=hash-value'); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/common/services/legacy_es.js b/x-pack/test/spaces_api_integration/common/services/legacy_es.js index 9267fa312ed0654..c8bf1810daafeed 100644 --- a/x-pack/test/spaces_api_integration/common/services/legacy_es.js +++ b/x-pack/test/spaces_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); diff --git a/x-pack/test/token_api_integration/auth/login.js b/x-pack/test/token_api_integration/auth/login.js index 7b68298a521682c..b2dd870e018dadf 100644 --- a/x-pack/test/token_api_integration/auth/login.js +++ b/x-pack/test/token_api_integration/auth/login.js @@ -17,20 +17,30 @@ export default function ({ getService }) { } describe('login', () => { - it('accepts valid login credentials as 204 status', async () => { + it('accepts valid login credentials as 200 status', async () => { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }) - .expect(204); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) + .expect(200); }); it('sets HttpOnly cookie with valid login', async () => { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }) - .expect(204); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) + .expect(200); const cookie = extractSessionCookie(response); if (!cookie) { @@ -45,7 +55,12 @@ export default function ({ getService }) { it('rejects without kbn-xsrf header as 400 status even if credentials are valid', async () => { const response = await supertest .post('/internal/security/login') - .send({ username: 'elastic', password: 'changeme' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -68,7 +83,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -80,7 +100,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ password: 'changme' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { password: 'changeme' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -92,7 +117,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'notvalidpassword' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'notvalidpassword' }, + }) .expect(401); if (extractSessionCookie(response)) { diff --git a/x-pack/test/token_api_integration/auth/logout.js b/x-pack/test/token_api_integration/auth/logout.js index cc63c54a9434523..fcc0e8182158f23 100644 --- a/x-pack/test/token_api_integration/auth/logout.js +++ b/x-pack/test/token_api_integration/auth/logout.js @@ -20,7 +20,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }); const cookie = extractSessionCookie(response); if (!cookie) { @@ -68,7 +73,7 @@ export default function ({ getService }) { .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) - .expect(400); + .expect(401); }); }); } diff --git a/x-pack/test/token_api_integration/auth/session.js b/x-pack/test/token_api_integration/auth/session.js index 3967a44e593f9c5..1f69b06315a80e1 100644 --- a/x-pack/test/token_api_integration/auth/session.js +++ b/x-pack/test/token_api_integration/auth/session.js @@ -23,7 +23,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }); const cookie = extractSessionCookie(response); if (!cookie) {