From bfd42f034fd8b401da5cf55758298712b763e485 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 3 Jun 2020 13:19:58 +0200 Subject: [PATCH 01/28] 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 | 386 ++++++++++ .../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, 3951 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 6657f5c0a900c..c650763ed481a 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 0000000000000..c5b9245414630 --- /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 0000000000000..81cce1499288c --- /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 0000000000000..6dc1c2f7e2c27 --- /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 39131f9f4499f..552d523fa4a84 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 ec631e8a2b525..9ea553af75e00 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 7422319951a8a..1fc8824eeff3a 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 455cc9fb9ce1f..ee8784cdd0f9f 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 826665a3b8a30..a5e744ba36915 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 300447096af99..abd02aff540e1 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 42ec3f79bddf3..a6cbc218985dc 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 7cd3ac18634f7..299a75335a64c 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 56d44e6628a87..8754082c94699 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 659a378388a13..03ba5fa429389 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 aea5994e3ba3e..83e172c419a7d 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 ac7374401f99a..8497bcc6ba46c 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 851ecf8107ad2..16043526fcd75 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 d121cd4979aa7..40f86aa5e20c6 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 f67e0863086bb..2fdc2d169e972 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 cae273ecac338..4190499cbd5f4 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 6ba33b2cccb7c..b1200d26b8379 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 051a3d2ab1342..0b6397aa63184 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 0000000000000..792b54ec553a6 --- /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 0000000000000..291c6e9e3a6db --- /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 0000000000000..793bdc1c6ad26 --- /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 d357519c5ccce..00ad962115901 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 a7b958ee02de5..d2666c1916341 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 c4b16c9eec872..b43a1d9a8ea39 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 944bc567de586..0000000000000 --- 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 ccc6a8df24d6e..0000000000000 --- 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 5a0401e6320b4..8d800595d28ed 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 ad38a158af2b9..a37f20c9ef82c 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 d09f65525f44e..6527fd0220584 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 ce7516c2c9d88..58ec7f559bc28 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 24de2af5e9703..b4698708f86fe 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 82c0186898d38..a3f046ae4f9e6 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 cdebc19d7cf8d..722636aa9934a 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 0000000000000..aeed027972ed0 --- /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 0000000000000..0c6d173b80d77 --- /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 21c7fc1340437..4cbc9d81b872c 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 e915cd8759ff1..66dc25295f29b 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 3d616575b8413..e1a3ff9b040b7 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 49e1ff42a28a2..80a1c2a20cf59 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 0000000000000..690c68dcd59aa --- /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 0c0117dec5390..fa2088a80b183 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 b9de58d47fe40..64d288dfc7c7d 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 7cb73c49f9cbc..a8bb71a29a902 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 43c2f01b1b53d..b35154e6a0f2a 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 0000000000000..e1b766131a3b1 --- /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 0000000000000..ee7ed914947a0 --- /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 0000000000000..0e43c659c3fae --- /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 0000000000000..adff731e359b9 --- /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 0000000000000..856037f294309 --- /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 0000000000000..4ee6f5767b0ca --- /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 0000000000000..3244f9e340787 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -0,0 +1,386 @@ +/* + * 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; + + /** + * Promise that tracks session index initialization process. We'll need to get rid of this as soon + * as Core provides support for plugin statuses. With this we won't mark Security as `Green` until + * index is fully initialized and hence consumers won't be able to call any API we provide. + */ + private indexInitialization?: Promise; + + 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.indexInitialization) { + this.#options.logger.warn( + 'Attempted to create a new session while session index is initializing.' + ); + await this.indexInitialization; + } + + 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() { + if (this.indexInitialization) { + return await this.indexInitialization; + } + + this.indexInitialization = new Promise(async (resolve) => { + // 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; + } + } + + // Notify any consumers that are awaiting on this promise and immediately reset it. + resolve(); + this.indexInitialization = undefined; + }); + } +} 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 0000000000000..f9baa03fc5bb7 --- /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 4479c8d9d1fbe..f46d2c65c565f 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 29be6d826c1bc..c52a88806fdc8 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 284330cf0fc9d..70eddc9aee4d8 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 217c239596690..48892b2347092 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 ddd36f3322558..64ecdda201301 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 0ea061365aca2..04b991151034a 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 8d2e575fad313..38a8697e05252 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 54b37fe52cc56..439e553b17a86 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 0ef60bb929826..0acae074f129f 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 f91eb492afe24..18dfdcffef363 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 f35c72ea135c9..39a8426fc30f0 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 7a0d786e20130..08aa0a6d9c0dd 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 33e608d0b18f1..664fdb9fba67a 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 ab33ecc1eb87a..d78f4da63ab5b 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 5777aa3f423f0..b9253a0493244 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 9267fa312ed06..c8bf1810daafe 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 0000000000000..d8f146e4c6f6b --- /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 e34baef0be477..69517154f2745 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 0000000000000..7f8bf944d0b1f --- /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 e0b776cd123c1..153387c52e5c3 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 0000000000000..0d1060fbf1f51 --- /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 99d600c1eafda..4b3d6a925bf76 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 0000000000000..5d47d80efadcb --- /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 9267fa312ed06..c8bf1810daafe 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 7b68298a52168..b2dd870e018da 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 cc63c54a94345..fcc0e8182158f 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 3967a44e593f9..1f69b06315a80 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) { From 194b06e01578fd8d8bd21b1e2f04fdd578bec8cd Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jul 2020 10:59:31 +0200 Subject: [PATCH 02/28] Added functional tests for the OIDC capture URL flow, Authenticator tests for the "pre-access" redirects, minor enhancements. --- .../authentication/authenticator.test.ts | 670 ++++++++++++++++-- .../server/authentication/authenticator.ts | 14 +- x-pack/scripts/functional_tests.js | 1 + .../oidc_provider/server/init_routes.ts | 37 + .../test/security_functional/oidc.config.ts | 84 +++ .../security_functional/tests/oidc/index.ts | 15 + .../tests/oidc/url_capture.ts | 54 ++ 7 files changed, 820 insertions(+), 55 deletions(-) create mode 100644 x-pack/test/security_functional/oidc.config.ts create mode 100644 x-pack/test/security_functional/tests/oidc/index.ts create mode 100644 x-pack/test/security_functional/tests/oidc/url_capture.ts diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index abd02aff540e1..cfab5dec88ec3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -85,54 +85,6 @@ 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', () => { @@ -519,7 +471,7 @@ describe('Authenticator', () => { }); }); - it('clears session if provider asked to do so.', async () => { + it('clears session if provider asked to do so in `succeeded` result.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); mockOptions.session.get.mockResolvedValue(mockSessVal); @@ -538,6 +490,468 @@ describe('Authenticator', () => { expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); expect(mockOptions.session.clear).toHaveBeenCalledWith(request); }); + + it('clears session if provider asked to do so in `redirected` result.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('some-url', { state: null }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.redirectTo('some-url', { state: null })); + + 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('with Access Agreement', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0, accessAgreement: { message: 'some notice' } } }, + }, + }); + + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + + mockOptions.license.getFeatures.mockReturnValue({ + allowAccessAgreement: true, + } as SecurityLicenseFeatures); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Access Agreement if authenticated session is not created', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser)); + }); + + it('does not redirect to Access Agreement if request cannot be handled', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not redirect to Access Agreement if authentication fails', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + const failureReason = new Error('something went wrong'); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + }); + + it('does not redirect to Access Agreement if redirect is required to complete login', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.redirectTo('/some-url', { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if user has already acknowledged it', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + accessAgreementAcknowledged: true, + }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' }); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if it is not configured', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.session.get.mockResolvedValue(mockSessVal); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if license doesnt allow it.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + mockOptions.license.getFeatures.mockReturnValue({ + allowAccessAgreement: false, + } as SecurityLicenseFeatures); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('redirects to Access Agreement when needed.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Access Agreement preserving redirect URL specified in login attempt.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { + provider: { type: 'basic' }, + value: {}, + redirectURL: '/some-url', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Access Agreement preserving redirect URL specified in the authentication result.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects AJAX requests to Access Agreement when needed.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + }); + + describe('with Overwritten Session', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + }); + + it('does not redirect to Overwritten Session its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/security/overwritten_session', + }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Overwritten Session if username and provider did not change', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if session was unauthenticated before login', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + + const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('redirects to Overwritten Session when username changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session when provider changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in login attempt.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { + provider: { type: 'basic' }, + value: {}, + redirectURL: '/some-url', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects AJAX requests to Overwritten Session when needed.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + }); }); describe('`authenticate` method', () => { @@ -1069,6 +1483,164 @@ describe('Authenticator', () => { ); }); }); + + describe('with Overwritten Session', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + }); + + it('does not redirect to Overwritten Session its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/security/overwritten_session', + }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser) + ); + }); + + it('does not redirect AJAX requests to Overwritten Session', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if username and provider did not change', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if session was unauthenticated before this authentication attempt', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + + const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('redirects to Overwritten Session when username changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session when provider changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index a6cbc218985dc..59422aef08664 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -360,7 +360,7 @@ export class Authenticator { } const queryStringProviderName = (request.query as Record)?.provider; - if (typeof queryStringProviderName === 'string') { + if (queryStringProviderName) { // 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(queryStringProviderName); @@ -461,7 +461,7 @@ export class Authenticator { // 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 // of providers. - if (!sessionValue) { + if (!sessionValue || !this.providers.has(sessionValue.provider.name)) { yield* this.providers; } else { yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!]; @@ -516,10 +516,10 @@ export class Authenticator { // If authentication succeeds or requires redirect we should automatically extend existing user session, // unless authentication has been triggered by a system API request. In case provider explicitly returns new // state we should store it in the session regardless of whether it's a system API request or not. - const sessionCanBeUpdated = + const sessionShouldBeUpdatedOrExtended = (authenticationResult.succeeded() || authenticationResult.redirected()) && (authenticationResult.shouldUpdateState() || (!request.isSystemRequest && ownsSession)); - if (!sessionCanBeUpdated) { + if (!sessionShouldBeUpdatedOrExtended) { return ownsSession ? { value: existingSessionValue, overwritten: false } : null; } @@ -653,12 +653,14 @@ export class Authenticator { return authenticationResult; } + const isSessionAuthenticated = !!sessionUpdateResult?.value?.username; + let preAccessRedirectURL; - if (sessionUpdateResult?.overwritten) { + if (isSessionAuthenticated && sessionUpdateResult?.overwritten) { this.logger.debug('Redirecting user to the overwritten session UI.'); preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; } else if ( - authenticationResult.succeeded() && + isSessionAuthenticated && this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) ) { this.logger.debug('Redirecting user to the access agreement UI.'); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index c52a88806fdc8..2e63cc6177a28 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -10,6 +10,7 @@ const alwaysImportedTests = [ require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), require.resolve('../test/security_functional/login_selector.config.ts'), + require.resolve('../test/security_functional/oidc.config.ts'), require.resolve('../test/security_functional/saml.config.ts'), ]; const onlyNotInCoverageTests = [ diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts index 385cbfc1ec099..73f92139806e3 100644 --- a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts @@ -4,11 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../../src/core/server'; import { createTokens } from '../../oidc_tools'; export function initRoutes(router: IRouter) { let nonce = ''; + router.get( + { + path: '/oidc_provider/authorize', + validate: { + query: schema.object( + { redirect_uri: schema.string(), state: schema.string(), nonce: schema.string() }, + { unknowns: 'ignore' } + ), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + nonce = request.query.nonce; + + return response.redirected({ + headers: { + location: `${request.query.redirect_uri}?code=code1&state=${request.query.state}`, + }, + }); + } + ); + + router.get( + { + path: '/oidc_provider/endsession', + validate: { + query: schema.object({ post_logout_redirect_uri: schema.string() }, { unknowns: 'ignore' }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + return response.redirected({ + headers: { location: request.query.post_logout_redirect_uri || '/' }, + }); + } + ); router.post( { diff --git a/x-pack/test/security_functional/oidc.config.ts b/x-pack/test/security_functional/oidc.config.ts new file mode 100644 index 0000000000000..e21fae6d35ea4 --- /dev/null +++ b/x-pack/test/security_functional/oidc.config.ts @@ -0,0 +1,84 @@ +/* + * 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 jwksPath = resolve(__dirname, '../oidc_api_integration/fixtures/jwks.json'); + const oidcOpPPlugin = resolve(__dirname, '../oidc_api_integration/fixtures/oidc_provider'); + + return { + testFiles: [resolve(__dirname, './tests/oidc')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'trial', + from: 'snapshot', + serverArgs: [ + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.realms.oidc.oidc1.order=0', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/oidc/callback`, + `xpack.security.authc.realms.oidc.oidc1.rp.post_logout_redirect_uri=http://localhost:${kibanaPort}/security/logged_out`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=http://localhost:${kibanaPort}/oidc_provider/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=http://localhost:${kibanaPort}/oidc_provider/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${jwksPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, + ], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${oidcOpPPlugin}`, + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + '--xpack.security.authc.selector.enabled=false', + '--xpack.security.authc.providers.oidc.oidc1.order=0', + '--xpack.security.authc.providers.oidc.oidc1.realm=oidc1', + '--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/security_functional/tests/oidc/index.ts b/x-pack/test/security_functional/tests/oidc/index.ts new file mode 100644 index 0000000000000..2b6e433409fb4 --- /dev/null +++ b/x-pack/test/security_functional/tests/oidc/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 - OIDC interactions', function () { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./url_capture')); + }); +} diff --git a/x-pack/test/security_functional/tests/oidc/url_capture.ts b/x-pack/test/security_functional/tests/oidc/url_capture.ts new file mode 100644 index 0000000000000..bb4917f18fc1c --- /dev/null +++ b/x-pack/test/security_functional/tests/oidc/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/oidc1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'oidc1' } } }) + .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'); + }); + }); +} From 176fe3bf7ec425b4dccad068ad7f015bdd04aa5f Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jul 2020 16:35:30 +0200 Subject: [PATCH 03/28] Remove session that belongs to a not configured provider in all cases. --- .../authentication/authenticator.test.ts | 18 ++++++++ .../server/authentication/authenticator.ts | 43 ++++++++++++++++--- .../server/session_management/session.ts | 25 ++--------- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index cfab5dec88ec3..49a6bdfd46569 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -81,6 +81,7 @@ describe('Authenticator', () => { jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ type: 'saml', + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), })); }); @@ -753,12 +754,21 @@ describe('Authenticator', () => { describe('with Overwritten Session', () => { const mockUser = mockAuthenticatedUser(); beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' } }, + }, + }); + mockOptions.session.get.mockResolvedValue(null); mockOptions.session.update.mockImplementation(async (request, value) => value); mockOptions.session.extend.mockImplementation(async (request, value) => value); mockOptions.session.create.mockImplementation(async (request, value) => ({ ...mockSessVal, ...value, })); + + authenticator = new Authenticator(mockOptions); }); it('does not redirect to Overwritten Session its own requests', async () => { @@ -1487,12 +1497,20 @@ describe('Authenticator', () => { describe('with Overwritten Session', () => { const mockUser = mockAuthenticatedUser(); beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' } }, + }, + }); mockOptions.session.update.mockImplementation(async (request, value) => value); mockOptions.session.extend.mockImplementation(async (request, value) => value); mockOptions.session.create.mockImplementation(async (request, value) => ({ ...mockSessVal, ...value, })); + + authenticator = new Authenticator(mockOptions); }); it('does not redirect to Overwritten Session its own requests', async () => { diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 59422aef08664..d362d7ee4feb6 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -244,7 +244,7 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - const existingSessionValue = await this.session.get(request); + const existingSessionValue = await this.getSessionValue(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 @@ -308,7 +308,7 @@ export class Authenticator { async authenticate(request: KibanaRequest) { assertRequest(request); - const existingSessionValue = await this.session.get(request); + const existingSessionValue = await this.getSessionValue(request); if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) { this.logger.debug('Redirecting request to Login Selector.'); @@ -353,7 +353,7 @@ export class Authenticator { async logout(request: KibanaRequest) { assertRequest(request); - const sessionValue = await this.session.get(request); + const sessionValue = await this.getSessionValue(request); if (sessionValue) { await this.session.clear(request); return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); @@ -400,7 +400,7 @@ export class Authenticator { async acknowledgeAccessAgreement(request: KibanaRequest) { assertRequest(request); - const existingSessionValue = await this.session.get(request); + const existingSessionValue = await this.getSessionValue(request); const currentUser = this.options.getCurrentUser(request); if (!existingSessionValue || !currentUser) { throw new Error('Cannot acknowledge access agreement for unauthenticated user.'); @@ -461,7 +461,7 @@ export class Authenticator { // 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 // of providers. - if (!sessionValue || !this.providers.has(sessionValue.provider.name)) { + if (!sessionValue) { yield* this.providers; } else { yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!]; @@ -474,6 +474,39 @@ 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 request Request instance. + */ + private async getSessionValue(request: KibanaRequest) { + const existingSessionValue = await this.session.get(request); + + // 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 ( + existingSessionValue && + this.providers.get(existingSessionValue.provider.name)?.type !== + existingSessionValue.provider.type + ) { + this.logger.warn( + `Attempted to retrieve session for the "${existingSessionValue.provider.type}/${existingSessionValue.provider.name}" provider, but it is not configured.` + ); + await this.session.clear(request); + return null; + } + + return existingSessionValue; + } + + /** + * Updates, creates, extends or clears session value based on the received authentication result. + * @param request Request instance. + * @param provider Provider that produced provided authentication result. + * @param authenticationResult Result of the authentication or login attempt. + * @param existingSessionValue Value of the existing session if any. + */ private async updateSessionValue( request: KibanaRequest, { diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 856037f294309..30f33ca07e566 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -75,7 +75,7 @@ export interface SessionOptions { logger: Logger; sessionIndex: SessionIndex; sessionCookie: SessionCookie; - config: Pick; + config: Pick; } interface SessionValueContentToEncrypt { @@ -84,11 +84,6 @@ interface SessionValueContentToEncrypt { } 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. */ @@ -122,9 +117,6 @@ export class Session { 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; @@ -135,8 +127,7 @@ export class Session { /** * 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. + * invalid or created by the legacy versions of Kibana. * @param request Request instance to get session value for. */ async get(request: KibanaRequest) { @@ -164,17 +155,6 @@ export class Session { 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)), @@ -182,6 +162,7 @@ export class Session { idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration, }; } catch (err) { + this.#options.logger.warn('Unable to decrypt session content, session will be invalidated.'); await this.clear(request); return null; } From 3f85d63b3ee65d5901de8eaa02c2f81b81839171 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 8 Jul 2020 18:27:54 +0200 Subject: [PATCH 04/28] More tests, don't clean session on password change (temporarily). --- .../elasticsearch_service.test.ts | 197 ++++++++++++++---- .../elasticsearch/elasticsearch_service.ts | 3 + .../routes/session_management/extend.test.ts | 58 ++++++ .../routes/session_management/info.test.ts | 107 ++++++++++ .../server/routes/session_management/info.ts | 1 + .../routes/users/change_password.test.ts | 3 + .../server/routes/users/change_password.ts | 5 +- .../server/session_management/session.ts | 5 +- .../apis/security/change_password.ts | 8 +- 9 files changed, 336 insertions(+), 51 deletions(-) create mode 100644 x-pack/plugins/security/server/routes/session_management/extend.test.ts create mode 100644 x-pack/plugins/security/server/routes/session_management/info.test.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 index 792b54ec553a6..29bcf101f045c 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -4,6 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { BehaviorSubject } from 'rxjs'; +import { + ILegacyCustomClusterClient, + ServiceStatusLevels, + CoreStatus, +} from '../../../../../src/core/server'; +import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { ElasticsearchService } from './elasticsearch_service'; @@ -13,6 +20,7 @@ import { loggingSystemMock, } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; +import { nextTick } from 'test_utils/enzyme_helpers'; describe('ElasticsearchService', () => { let service: ElasticsearchService; @@ -26,7 +34,7 @@ describe('ElasticsearchService', () => { const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - await expect( + expect( service.setup({ elasticsearch: mockCoreSetup.elasticsearch, status: mockCoreSetup.status, @@ -41,48 +49,155 @@ describe('ElasticsearchService', () => { }); }); - describe('start', () => { - /* -it('schedules retries if fails to register cluster privileges', async () => { - jest.useFakeTimers(); + describe('start()', () => { + let mockClusterClient: ILegacyCustomClusterClient; + let mockLicense: jest.Mocked; + let mockStatusSubject: BehaviorSubject; + let mockLicenseSubject: BehaviorSubject; + beforeEach(() => { + const mockCoreSetup = coreMock.createSetup(); + mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockRegisterPrivilegesWithCluster.mockRejectedValue(new Error('Some error')); + mockLicenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); + mockLicense = licenseMock.create(); + mockLicense.isEnabled.mockReturnValue(false); + mockLicense.features$ = mockLicenseSubject; + + mockStatusSubject = new BehaviorSubject({ + elasticsearch: { + level: ServiceStatusLevels.unavailable, + summary: 'Service is NOT working', + }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + mockCoreSetup.status.core$ = mockStatusSubject; - // 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); -});*/ + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: mockLicense, + }); + }); + + it('exposes proper contract', async () => { + expect(service.start()).toEqual({ + clusterClient: mockClusterClient, + watchOnlineStatus$: expect.any(Function), + }); + }); + + it('`watchOnlineStatus$` allows tracking of Elasticsearch status', async () => { + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + + // Neither ES nor license is available yet. + expect(mockHandler).not.toHaveBeenCalled(); + + // ES is available now, but not license. + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + expect(mockHandler).not.toHaveBeenCalled(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it('`watchOnlineStatus$` allows to schedule retry', async () => { + jest.useFakeTimers(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + expect(mockHandler).toHaveBeenCalledTimes(1); + + const [[{ scheduleRetry }]] = mockHandler.mock.calls; + + // Next retry isn't performed immediately, retry happens only after a timeout. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(2); + + // Delay between consequent retries is increasing. + scheduleRetry(); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(2); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(3); + + // Delay between consequent retries is increasing. + scheduleRetry(); + await nextTick(); + jest.advanceTimersByTime(200); + expect(mockHandler).toHaveBeenCalledTimes(3); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(4); + + // If `scheduleRetry` isn't called retries aren't scheduled anymore. + await nextTick(); + jest.runAllTimers(); + expect(mockHandler).toHaveBeenCalledTimes(4); + + // New changes still trigger handler once again and reset retry timer. + mockLicenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + expect(mockHandler).toHaveBeenCalledTimes(5); + + // Retry timer is reset. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(5); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(6); + }); + + it('`watchOnlineStatus$` cancels scheduled retry if status changes before retry timeout fires', async () => { + jest.useFakeTimers(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + expect(mockHandler).toHaveBeenCalledTimes(1); + + const [[{ scheduleRetry }]] = mockHandler.mock.calls; + + // Schedule a retry. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // New changes should immediately call handler. + mockLicenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + expect(mockHandler).toHaveBeenCalledTimes(2); + + // Retry timeout should have been cancelled. + await nextTick(); + jest.runAllTimers(); + expect(mockHandler).toHaveBeenCalledTimes(2); + }); }); describe('stop()', () => { diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts index 291c6e9e3a6db..c67198e69428c 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -36,6 +36,9 @@ export interface OnlineStatusRetryScheduler { scheduleRetry: () => void; } +/** + * Service responsible for interactions with the Elasticsearch. + */ export class ElasticsearchService { readonly #logger: Logger; #clusterClient?: ILegacyCustomClusterClient; diff --git a/x-pack/plugins/security/server/routes/session_management/extend.test.ts b/x-pack/plugins/security/server/routes/session_management/extend.test.ts new file mode 100644 index 0000000000000..235fce152510c --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/extend.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { defineSessionExtendRoutes } from './extend'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Extend session routes', () => { + let router: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + defineSessionExtendRoutes(routeParamsMock); + }); + + describe('extend session', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [extendRouteConfig, extendRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/session' + )!; + + routeConfig = extendRouteConfig; + routeHandler = extendRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('always returns 302.', async () => { + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ + status: 302, + options: { headers: { location: '/mock-server-basepath/internal/security/session' } }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts new file mode 100644 index 0000000000000..bf547ce0c35f8 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/info.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 { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { Session } from '../../session_management'; +import { defineSessionInfoRoutes } from './info'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Info session routes', () => { + let router: jest.Mocked; + let session: jest.Mocked>; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + session = routeParamsMock.session; + + defineSessionInfoRoutes(routeParamsMock); + }); + + describe('extend session', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [extendRouteConfig, extendRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/session' + )!; + + routeConfig = extendRouteConfig; + routeHandler = extendRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: 'optional' }); + expect(routeConfig.validate).toBe(false); + }); + + it('returns 500 if unhandled exception is thrown when session is retrieved.', async () => { + const unhandledException = new Error('Something went wrong.'); + session.get.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest(); + await expect( + routeHandler(({} as unknown) as RequestHandlerContext, request, kibanaResponseFactory) + ).resolves.toEqual({ + status: 500, + options: {}, + payload: 'Internal Error', + }); + + expect(session.get).toHaveBeenCalledWith(request); + }); + + it('returns session info.', async () => { + session.get.mockResolvedValue( + sessionMock.createSessionValue({ + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + }) + ); + + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(1234); + + const expectedBody = { + now: 1234, + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + }; + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ + status: 200, + payload: expectedBody, + options: { body: expectedBody }, + }); + }); + + it('returns empty response if session is not available.', async () => { + session.get.mockResolvedValue(null); + + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ status: 200, options: {} }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 0c6d173b80d77..118b5a403d8ea 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -15,6 +15,7 @@ export function defineSessionInfoRoutes({ router, logger, session }: RouteDefini { path: '/internal/security/session', validate: false, + // We have both authenticated and non-authenticated sessions. options: { authRequired: 'optional' }, }, async (_context, request, response) => { 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 4cbc9d81b872c..b61da5a4a5c98 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 @@ -198,6 +198,9 @@ describe('Change password', () => { }); authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); + session.get.mockResolvedValue( + sessionMock.createSessionValue({ provider: { type: 'token', name: 'token1' } }) + ); const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); 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 66dc25295f29b..be868f841eeeb 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -81,11 +81,8 @@ 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 }, + provider: { name: currentSession.provider.name }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 30f33ca07e566..949ddad6621a0 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -136,10 +136,11 @@ export class Session { return null; } + const now = Date.now(); if ( (sessionCookieValue.idleTimeoutExpiration && - sessionCookieValue.idleTimeoutExpiration < Date.now()) || - (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < Date.now()) + sessionCookieValue.idleTimeoutExpiration < now) || + (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < now) ) { this.#options.logger.debug('Session has expired and will be invalidated.'); await this.clear(request); 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 48892b2347092..15b04cb7069a4 100644 --- a/x-pack/test/api_integration/apis/security/change_password.ts +++ b/x-pack/test/api_integration/apis/security/change_password.ts @@ -94,14 +94,14 @@ export default function ({ getService }: FtrProviderContext) { const newSessionCookie = cookie(passwordChangeResponse.headers['set-cookie'][0])!; - // Let's check that previous cookie isn't valid anymore. + // Old cookie is still valid (since it's still the same user and cookie doesn't store password). await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(401); + .expect(200); - // And that we can't login with the old password. + // But we can't login with the old password. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') @@ -113,7 +113,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(401); - // But new cookie should be valid. + // New cookie should be valid. await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') From ada2b9f78dd0bd8e1e8a8f81c6bf5fda07e34805 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 9 Jul 2020 13:34:01 +0200 Subject: [PATCH 05/28] Add capture-url tests, session cookie, and session management service tests. --- .../server/authentication/index.test.ts | 9 ++ .../elasticsearch_service.test.ts | 10 +- x-pack/plugins/security/server/plugin.ts | 1 - .../server/routes/views/capture_url.test.ts | 99 ++++++++++++ .../server/session_management/session.mock.ts | 13 ++ .../server/session_management/session.ts | 2 - .../session_management/session_cookie.test.ts | 149 ++++++++++++++++++ .../session_management/session_cookie.ts | 29 ++-- .../session_management_service.test.ts | 146 +++++++++++++++++ .../session_management_service.ts | 4 - 10 files changed, 438 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/security/server/routes/views/capture_url.test.ts create mode 100644 x-pack/plugins/security/server/session_management/session_cookie.test.ts create mode 100644 x-pack/plugins/security/server/session_management/session_management_service.test.ts diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 8754082c94699..e91f1d8e5e0a0 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -88,6 +88,15 @@ describe('setupAuthentication()', () => { afterEach(() => jest.clearAllMocks()); + it('properly registers auth handler', async () => { + await setupAuthentication(mockSetupAuthenticationParams); + + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + }); + describe('authentication handler', () => { let authHandler: AuthenticationHandler; let authenticate: jest.SpyInstance, [KibanaRequest]>; diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts index 29bcf101f045c..073b0b6225478 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -29,7 +29,7 @@ describe('ElasticsearchService', () => { }); describe('setup()', () => { - it('exposes proper contract', async () => { + it('exposes proper contract', () => { const mockCoreSetup = coreMock.createSetup(); const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); @@ -80,14 +80,14 @@ describe('ElasticsearchService', () => { }); }); - it('exposes proper contract', async () => { + it('exposes proper contract', () => { expect(service.start()).toEqual({ clusterClient: mockClusterClient, watchOnlineStatus$: expect.any(Function), }); }); - it('`watchOnlineStatus$` allows tracking of Elasticsearch status', async () => { + it('`watchOnlineStatus$` allows tracking of Elasticsearch status', () => { const mockHandler = jest.fn(); service.start().watchOnlineStatus$().subscribe(mockHandler); @@ -201,7 +201,7 @@ describe('ElasticsearchService', () => { }); describe('stop()', () => { - it('properly closes cluster client instance', async () => { + it('properly closes cluster client instance', () => { const mockCoreSetup = coreMock.createSetup(); const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); @@ -214,7 +214,7 @@ describe('ElasticsearchService', () => { expect(mockClusterClient.close).not.toHaveBeenCalled(); - await service.stop(); + service.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 b43a1d9a8ea39..8e28c0cc5c873 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -149,7 +149,6 @@ export class Plugin { const auditLogger = new SecurityAuditLogger(audit.getLogger()); const { session } = this.sessionManagementService.setup({ - auditLogger, config, clusterClient, http: core.http, diff --git a/x-pack/plugins/security/server/routes/views/capture_url.test.ts b/x-pack/plugins/security/server/routes/views/capture_url.test.ts new file mode 100644 index 0000000000000..2b2aab3407eb3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/capture_url.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Type } from '@kbn/config-schema'; +import { + RouteConfig, + HttpResources, + HttpResourcesRequestHandler, + RequestHandlerContext, +} from '../../../../../../src/core/server'; +import { defineCaptureURLRoutes } from './capture_url'; + +import { httpResourcesMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Capture URL view routes', () => { + let httpResources: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + httpResources = routeParamsMock.httpResources; + + defineCaptureURLRoutes(routeParamsMock); + }); + + let routeHandler: HttpResourcesRequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [viewRouteConfig, viewRouteHandler] = httpResources.register.mock.calls.find( + ([{ path }]) => path === '/internal/security/capture-url' + )!; + + routeConfig = viewRouteConfig; + routeHandler = viewRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + expect(routeConfig.validate).toEqual({ + body: undefined, + query: expect.any(Type), + params: undefined, + }); + + const queryValidator = (routeConfig.validate as any).query as Type; + expect( + queryValidator.validate({ providerType: 'basic', providerName: 'basic1', next: '/some-url' }) + ).toEqual({ providerType: 'basic', providerName: 'basic1', next: '/some-url' }); + + expect(queryValidator.validate({ providerType: 'basic', providerName: 'basic1' })).toEqual({ + providerType: 'basic', + providerName: 'basic1', + }); + + expect(() => queryValidator.validate({ providerType: '' })).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + queryValidator.validate({ providerType: 'basic' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: expected value of type [string] but got [undefined]"` + ); + + expect(() => queryValidator.validate({ providerName: '' })).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + queryValidator.validate({ providerName: 'basic1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + queryValidator.validate({ providerType: 'basic', providerName: '' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + queryValidator.validate({ providerType: '', providerName: 'basic1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('renders view.', async () => { + const request = httpServerMock.createKibanaRequest(); + const responseFactory = httpResourcesMock.createResponseFactory(); + + await routeHandler(({} as unknown) as RequestHandlerContext, request, responseFactory); + + expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts index 0e43c659c3fae..1dce36079cb58 100644 --- a/x-pack/plugins/security/server/session_management/session.mock.ts +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -7,6 +7,7 @@ import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { Session, SessionValue } from './session'; import { SessionIndexValue } from './session_index'; +import { SessionCookieValue } from './session_cookie'; const createSessionIndexValue = ( sessionValue: Partial = {} @@ -22,6 +23,17 @@ const createSessionIndexValue = ( ...sessionValue, }); +const createSessionCookieValue = ( + sessionValue: Partial = {} +): SessionCookieValue => ({ + sid: 'some-long-sid', + aad: 'some-aad', + idleTimeoutExpiration: null, + lifespanExpiration: null, + path: '/', + ...sessionValue, +}); + export const sessionMock = { create: (): jest.Mocked> => ({ get: jest.fn(), @@ -44,4 +56,5 @@ export const sessionMock = { }), createSessionIndexValue, + createSessionCookieValue, }; diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 949ddad6621a0..ca8147c0fa76d 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -10,7 +10,6 @@ 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'; @@ -70,7 +69,6 @@ export interface SessionValue { } export interface SessionOptions { - auditLogger: SecurityAuditLogger; serverBasePath: string; logger: Logger; sessionIndex: SessionIndex; diff --git a/x-pack/plugins/security/server/session_management/session_cookie.test.ts b/x-pack/plugins/security/server/session_management/session_cookie.test.ts new file mode 100644 index 0000000000000..98d63844063a8 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.test.ts @@ -0,0 +1,149 @@ +/* + * 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 { SessionStorage } from '../../../../../src/core/server'; +import { SessionCookie, SessionCookieOptions } from './session_cookie'; + +import { + loggingSystemMock, + httpServiceMock, + sessionStorageMock, + httpServerMock, +} from '../../../../../src/core/server/mocks'; +import { sessionMock } from './session.mock'; + +describe('Session cookie', () => { + let sessionCookieOptions: SessionCookieOptions; + let sessionCookie: SessionCookie; + let mockSessionStorageFactory: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + const config = { + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + sameSiteCookies: 'Strict' as 'Strict', + }; + + const httpSetupMock = httpServiceMock.createSetupContract(); + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorageFactory = sessionStorageMock.createFactory(); + mockSessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + httpSetupMock.createCookieSessionStorageFactory.mockResolvedValue(mockSessionStorageFactory); + + sessionCookieOptions = { + logger: loggingSystemMock.createLogger(), + serverBasePath: '/mock-base-path', + config, + createCookieSessionStorageFactory: httpSetupMock.createCookieSessionStorageFactory, + }; + + sessionCookie = new SessionCookie(sessionCookieOptions); + }); + + describe('#constructor', () => { + it('properly creates CookieSessionStorageFactory', () => { + expect(sessionCookieOptions.createCookieSessionStorageFactory).toHaveBeenCalledTimes(1); + expect(sessionCookieOptions.createCookieSessionStorageFactory).toHaveBeenCalledWith({ + encryptionKey: sessionCookieOptions.config.encryptionKey, + isSecure: sessionCookieOptions.config.secureCookies, + name: sessionCookieOptions.config.cookieName, + sameSite: sessionCookieOptions.config.sameSiteCookies, + validate: expect.any(Function), + }); + }); + + it('cookie validator properly handles cookies with different base path', () => { + const [ + [{ validate }], + ] = (sessionCookieOptions.createCookieSessionStorageFactory as jest.Mock).mock.calls; + + expect( + validate( + sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }) + ) + ).toEqual({ isValid: true }); + + expect( + validate([ + sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), + sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), + ]) + ).toEqual({ isValid: true }); + + expect(validate(sessionMock.createSessionCookieValue({ path: '/some-old-path' }))).toEqual({ + isValid: false, + path: '/some-old-path', + }); + + expect( + validate([ + sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), + sessionMock.createSessionCookieValue({ path: '/some-old-path' }), + ]) + ).toEqual({ isValid: false, path: '/some-old-path' }); + }); + }); + + describe('#get', () => { + it('returns `null` if session storage returns `null`', async () => { + mockSessionStorage.get.mockResolvedValue(null); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBeNull(); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('returns value if session is in compatible format', async () => { + const sessionValue = sessionMock.createSessionCookieValue(); + mockSessionStorage.get.mockResolvedValue(sessionValue); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBe(sessionValue); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('returns `null` and clears session value if it is in incompatible format', async () => { + const invalidValue = sessionMock.createSessionCookieValue(); + delete invalidValue.sid; + + mockSessionStorage.get.mockResolvedValue(invalidValue); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBeNull(); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).toHaveBeenCalledTimes(1); + }); + }); + + describe('#set', () => { + it('properly sets value in the session storage', async () => { + const sessionValue = sessionMock.createSessionCookieValue(); + + const request = httpServerMock.createKibanaRequest(); + await sessionCookie.set(request, sessionValue); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith(sessionValue); + }); + }); + + describe('#clear', () => { + it('properly clears value in the session storage', async () => { + const request = httpServerMock.createKibanaRequest(); + await sessionCookie.clear(request); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts index 4ee6f5767b0ca..565a0b1887ec0 100644 --- a/x-pack/plugins/security/server/session_management/session_cookie.ts +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -60,26 +60,31 @@ export class SessionCookie { readonly #cookieSessionValueStorage: Promise>>; /** - * Options used to create Session Cookie. + * Session cookie logger. */ - 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, + readonly #logger: Logger; + + constructor({ + config, + createCookieSessionStorageFactory, + logger, + serverBasePath, + }: Readonly) { + this.#logger = logger; + this.#cookieSessionValueStorage = createCookieSessionStorageFactory({ + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + sameSite: 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); + ).find((sess) => sess.path !== undefined && sess.path !== serverBasePath); if (invalidSessionValue) { - options.logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); + this.#logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); return { isValid: false, path: invalidSessionValue.path }; } diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts new file mode 100644 index 0000000000000..d12c2d8ebaf53 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { Subject } from 'rxjs'; +import { ConfigSchema, createConfig } from '../config'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { SessionManagementService } from './session_management_service'; +import { Session } from './session'; + +import { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '../../../../../src/core/server/mocks'; +import { nextTick } from 'test_utils/enzyme_helpers'; +import { SessionIndex } from './session_index'; + +describe('SessionManagementService', () => { + let service: SessionManagementService; + beforeEach(() => { + service = new SessionManagementService(loggingSystemMock.createLogger()); + }); + + describe('setup()', () => { + it('exposes proper contract', () => { + const mockCoreSetup = coreMock.createSetup(); + + expect( + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + }) + ).toEqual({ session: expect.any(Session) }); + }); + }); + + describe('start()', () => { + let mockSessionIndexInitialize: jest.SpyInstance; + beforeEach(() => { + mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + + const mockCoreSetup = coreMock.createSetup(); + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + }); + }); + + afterEach(() => { + mockSessionIndexInitialize.mockReset(); + }); + + it('exposes proper contract', () => { + const mockStatusSubject = new Subject(); + expect(service.start({ online$: mockStatusSubject.asObservable() })).toBeUndefined(); + }); + + it('initializes session index when Elasticsearch goes online', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable() }); + + // ES isn't online yet. + expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + + expect(mockScheduleRetry).not.toHaveBeenCalled(); + }); + + it('schedules retry if index initialization fails', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable() }); + + mockSessionIndexInitialize.mockRejectedValue(new Error('ugh :/')); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockScheduleRetry).toHaveBeenCalledTimes(1); + + // Still fails. + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + + // And finally succeeds, retry is not scheduled. + mockSessionIndexInitialize.mockResolvedValue(undefined); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + }); + }); + + describe('stop()', () => { + let mockSessionIndexInitialize: jest.SpyInstance; + beforeEach(() => { + mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + + const mockCoreSetup = coreMock.createSetup(); + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + }); + }); + + afterEach(() => { + mockSessionIndexInitialize.mockReset(); + }); + + it('properly unsubscribes from status updates', () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable() }); + + service.stop(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + + expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); + expect(mockScheduleRetry).not.toHaveBeenCalled(); + }); + }); +}); 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 index f9baa03fc5bb7..e3994e2d4026b 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -6,7 +6,6 @@ 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'; @@ -14,7 +13,6 @@ import { SessionIndex } from './session_index'; import { Session } from './session'; export interface SessionManagementServiceSetupParams { - readonly auditLogger: SecurityAuditLogger; readonly http: Pick; readonly config: ConfigType; readonly clusterClient: ILegacyClusterClient; @@ -41,7 +39,6 @@ export class SessionManagementService { } setup({ - auditLogger, config, clusterClient, http, @@ -64,7 +61,6 @@ export class SessionManagementService { return { session: new Session({ - auditLogger, serverBasePath, logger: this.#logger, sessionCookie, From 9d3273b2975bb96d0c10ab4f5f209cdbd949509a Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 15 Jul 2020 18:17:15 +0200 Subject: [PATCH 06/28] Review#1: handle review comments, add more tests. --- x-pack/plugins/security/kibana.json | 2 +- .../authentication/authenticator.test.ts | 51 +- .../server/authentication/providers/saml.ts | 2 +- x-pack/plugins/security/server/config.test.ts | 141 ++- x-pack/plugins/security/server/config.ts | 8 + x-pack/plugins/security/server/plugin.test.ts | 2 + x-pack/plugins/security/server/plugin.ts | 11 +- .../routes/session_management/info.test.ts | 5 +- .../routes/users/change_password.test.ts | 4 +- .../routes/views/access_agreement.test.ts | 4 +- .../server/routes/views/logged_out.test.ts | 2 +- .../server/session_management/index.mock.ts | 2 + .../server/session_management/session.mock.ts | 36 +- .../server/session_management/session.test.ts | 1030 ++++++++++++----- .../server/session_management/session.ts | 251 ++-- .../session_management/session_cookie.mock.ts | 24 + .../session_management/session_cookie.test.ts | 27 +- .../session_management/session_cookie.ts | 29 +- .../session_management/session_index.mock.ts | 30 + .../session_management/session_index.test.ts | 534 +++++++++ .../session_management/session_index.ts | 216 ++-- .../session_management_service.test.ts | 93 +- .../session_management_service.ts | 75 +- 23 files changed, 1853 insertions(+), 726 deletions(-) create mode 100644 x-pack/plugins/security/server/session_management/session_cookie.mock.ts create mode 100644 x-pack/plugins/security/server/session_management/session_index.mock.ts create mode 100644 x-pack/plugins/security/server/session_management/session_index.test.ts diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 064ff5b6a6711..6a09e9e55a01b 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], - "requiredPlugins": ["data", "features", "licensing"], + "requiredPlugins": ["data", "features", "licensing", "taskManager"], "optionalPlugins": ["home", "management"], "server": true, "ui": true, diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 49a6bdfd46569..5a145d3e6a772 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -218,10 +218,7 @@ describe('Authenticator', () => { beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockOptions.session.get.mockResolvedValue(null); - mockSessVal = sessionMock.createSessionValue({ - state: { authorization: 'Basic xxx' }, - path: mockOptions.basePath.serverBasePath, - }); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -472,6 +469,37 @@ describe('Authenticator', () => { }); }); + it('clears session if it belongs to a not configured provider or 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.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); + }); + it('clears session if provider asked to do so in `succeeded` result.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); @@ -971,10 +999,7 @@ describe('Authenticator', () => { beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockOptions.session.get.mockResolvedValue(null); - mockSessVal = sessionMock.createSessionValue({ - state: { authorization: 'Basic xxx' }, - path: mockOptions.basePath.serverBasePath, - }); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -1667,10 +1692,7 @@ describe('Authenticator', () => { let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessVal = sessionMock.createSessionValue({ - state: { authorization: 'Basic xxx' }, - path: mockOptions.basePath.serverBasePath, - }); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -1763,10 +1785,7 @@ describe('Authenticator', () => { let mockSessionValue: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionValue = sessionMock.createSessionValue({ - state: { authorization: 'Basic xxx' }, - path: mockOptions.basePath.serverBasePath, - }); + mockSessionValue = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); mockOptions.session.get.mockResolvedValue(mockSessionValue); mockOptions.getCurrentUser.mockReturnValue(mockAuthenticatedUser()); mockOptions.license.getFeatures.mockReturnValue({ diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 40f86aa5e20c6..71e2a862b7b61 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -451,7 +451,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.failed(err); } - this.logger.debug('Login initiated by Identity Provider is successfully completed.'); + this.logger.debug('IdP initiated login completed successfully.'); return payloadAuthenticationResult; } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index b1200d26b8379..c7e86b800fbe5 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -50,6 +50,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -95,6 +96,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -139,6 +141,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -358,10 +361,10 @@ describe('config schema', () => { authc: { providers: { basic: { basic1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" + `); }); it('cannot be hidden from selector', () => { @@ -372,10 +375,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." + `); }); it('can have only provider of this type', () => { @@ -384,10 +387,10 @@ describe('config schema', () => { authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." + `); }); it('can be successfully validated', () => { @@ -418,10 +421,10 @@ describe('config schema', () => { authc: { providers: { token: { token1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" + `); }); it('cannot be hidden from selector', () => { @@ -432,10 +435,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." + `); }); it('can have only provider of this type', () => { @@ -444,10 +447,10 @@ describe('config schema', () => { authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.token]: Only one \\"token\\" provider can be configured." + `); }); it('can be successfully validated', () => { @@ -478,10 +481,10 @@ describe('config schema', () => { authc: { providers: { pki: { pki1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" + `); }); it('can have only provider of this type', () => { @@ -490,10 +493,10 @@ describe('config schema', () => { authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." + `); }); it('can be successfully validated', () => { @@ -522,10 +525,10 @@ describe('config schema', () => { authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" + `); }); it('can have only provider of this type', () => { @@ -536,10 +539,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." + `); }); it('can be successfully validated', () => { @@ -568,10 +571,10 @@ describe('config schema', () => { authc: { providers: { oidc: { oidc1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" + `); }); it('requires `realm`', () => { @@ -580,10 +583,10 @@ describe('config schema', () => { authc: { providers: { oidc: { oidc1: { order: 0 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" + `); }); it('can be successfully validated', () => { @@ -623,10 +626,10 @@ describe('config schema', () => { authc: { providers: { saml: { saml1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" + `); }); it('requires `realm`', () => { @@ -635,10 +638,10 @@ describe('config schema', () => { authc: { providers: { saml: { saml1: { order: 0 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" + `); }); it('can be successfully validated', () => { @@ -701,10 +704,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" + `); }); it('`order` should be unique across all provider types', () => { @@ -721,10 +724,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` -"[authc.providers]: types that failed validation: -- [authc.providers.0]: expected value of type [array] but got [Object] -- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" -`); + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" + `); }); it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { @@ -786,6 +789,16 @@ describe('config schema', () => { `); }); }); + + describe('session', () => { + it('should throw error if xpack.security.session.cleanupInterval is less than 1 minute', () => { + expect(() => + ConfigSchema.validate({ session: { cleanupInterval: '59s' } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[session.cleanupInterval]: the value must be greater or equal to 1 minute."` + ); + }); + }); }); describe('createConfig()', () => { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 0b6397aa63184..28098e320021e 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -149,6 +149,14 @@ export const ConfigSchema = schema.object({ session: schema.object({ idleTimeout: schema.nullable(schema.duration()), lifespan: schema.nullable(schema.duration()), + cleanupInterval: schema.duration({ + defaultValue: '1h', + validate(value) { + if (value.asSeconds() < 60) { + return 'the value must be greater or equal to 1 minute.'; + } + }, + }), }), secureCookies: schema.boolean({ defaultValue: false }), sameSiteCookies: schema.maybe( diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 417cd0310d80a..0f75b31a62122 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -11,6 +11,7 @@ import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../task_manager/server/mocks'; describe('Security Plugin', () => { let plugin: Plugin; @@ -43,6 +44,7 @@ describe('Security Plugin', () => { mockDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, + taskManager: taskManagerMock.createSetup(), } as unknown) as PluginSetupDependencies; }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 8e28c0cc5c873..75fb23047ce5e 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -20,6 +20,7 @@ import { PluginStartContract as FeaturesPluginStart, } from '../../features/server'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; +import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { Authentication, setupAuthentication } from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; @@ -69,11 +70,13 @@ export interface SecurityPluginSetup { export interface PluginSetupDependencies { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; + taskManager: TaskManagerSetupContract; } export interface PluginStartDependencies { features: FeaturesPluginStart; licensing: LicensingPluginStart; + taskManager: TaskManagerStartContract; } /** @@ -117,7 +120,7 @@ export class Plugin { public async setup( core: CoreSetup, - { features, licensing }: PluginSetupDependencies + { features, licensing, taskManager }: PluginSetupDependencies ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( @@ -152,6 +155,8 @@ export class Plugin { config, clusterClient, http: core.http, + kibanaIndexName: legacyConfig.kibana.index, + taskManager, }); const authc = await setupAuthentication({ @@ -235,7 +240,7 @@ export class Plugin { }); } - public start(core: CoreStart, { features, licensing }: PluginStartDependencies) { + public start(core: CoreStart, { features, licensing, taskManager }: PluginStartDependencies) { this.logger.debug('Starting plugin'); this.featureUsageServiceStart = this.featureUsageService.start({ @@ -244,7 +249,7 @@ export class Plugin { const { clusterClient, watchOnlineStatus$ } = this.elasticsearchService.start(); - this.sessionManagementService.start({ online$: watchOnlineStatus$() }); + this.sessionManagementService.start({ online$: watchOnlineStatus$(), taskManager }); this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); } diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts index bf547ce0c35f8..f7d9f9bbe1272 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -64,10 +64,7 @@ describe('Info session routes', () => { it('returns session info.', async () => { session.get.mockResolvedValue( - sessionMock.createSessionValue({ - idleTimeoutExpiration: 100, - lifespanExpiration: 200, - }) + sessionMock.createValue({ idleTimeoutExpiration: 100, lifespanExpiration: 200 }) ); const dateSpy = jest.spyOn(Date, 'now'); 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 b61da5a4a5c98..51bee1c74afa7 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 @@ -53,7 +53,7 @@ describe('Change password', () => { authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser(mockAuthenticatedUser())); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - session.get.mockResolvedValue(sessionMock.createSessionValue()); + session.get.mockResolvedValue(sessionMock.createValue()); mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockClusterClient = routeParamsMock.clusterClient; @@ -199,7 +199,7 @@ describe('Change password', () => { authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); session.get.mockResolvedValue( - sessionMock.createSessionValue({ provider: { type: 'token', name: 'token1' } }) + sessionMock.createValue({ provider: { type: 'token', name: 'token1' } }) ); const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); 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 e1a3ff9b040b7..9b73358223b3d 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 @@ -160,9 +160,7 @@ describe('Access agreement view routes', () => { ]; for (const [sessionProvider, expectedAccessAgreement] of cases) { - session.get.mockResolvedValue( - sessionMock.createSessionValue({ provider: sessionProvider }) - ); + session.get.mockResolvedValue(sessionMock.createValue({ provider: sessionProvider })); await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ options: { body: { accessAgreement: expectedAccessAgreement } }, 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 a8bb71a29a902..c160c4a26a187 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 @@ -39,7 +39,7 @@ describe('LoggedOut view routes', () => { }); it('redirects user to the root page if they have a session already.', async () => { - session.get.mockResolvedValue(sessionMock.createSessionValue()); + session.get.mockResolvedValue(sessionMock.createValue()); const request = httpServerMock.createKibanaRequest(); diff --git a/x-pack/plugins/security/server/session_management/index.mock.ts b/x-pack/plugins/security/server/session_management/index.mock.ts index e1b766131a3b1..ea7e77071d136 100644 --- a/x-pack/plugins/security/server/session_management/index.mock.ts +++ b/x-pack/plugins/security/server/session_management/index.mock.ts @@ -5,3 +5,5 @@ */ export { sessionMock } from './session.mock'; +export { sessionCookieMock } from './session_cookie.mock'; +export { sessionIndexMock } from './session_index.mock'; diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts index 1dce36079cb58..c09d24ba315c8 100644 --- a/x-pack/plugins/security/server/session_management/session.mock.ts +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -6,33 +6,7 @@ import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { Session, SessionValue } from './session'; -import { SessionIndexValue } from './session_index'; -import { SessionCookieValue } from './session_cookie'; - -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, -}); - -const createSessionCookieValue = ( - sessionValue: Partial = {} -): SessionCookieValue => ({ - sid: 'some-long-sid', - aad: 'some-aad', - idleTimeoutExpiration: null, - lifespanExpiration: null, - path: '/', - ...sessionValue, -}); +import { sessionIndexMock } from './session_index.mock'; export const sessionMock = { create: (): jest.Mocked> => ({ @@ -43,18 +17,14 @@ export const sessionMock = { clear: jest.fn(), }), - createSessionValue: (sessionValue: Partial = {}): SessionValue => ({ + createValue: (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) }, + metadata: { index: sessionIndexMock.createValue(sessionValue.metadata?.index) }, ...sessionValue, }), - - createSessionIndexValue, - createSessionCookieValue, }; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index adff731e359b9..13dbdb7b15b0c 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -4,355 +4,769 @@ * 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); +import crypto from 'crypto'; +import nodeCrypto from '@elastic/node-crypto'; +import { ConfigSchema, createConfig } from '../config'; +import { Session, SessionValueContentToEncrypt } from './session'; +import { SessionIndex } from './session_index'; +import { SessionCookie } from './session_cookie'; - await expect(getSessionInfo(httpServerMock.createKibanaRequest())).resolves.toEqual(mockInfo); - }); +import { loggingSystemMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { sessionMock, sessionCookieMock, sessionIndexMock } from './index.mock'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; - it('returns `null` if session does not exist.', async () => { - sessionMockInstance.get.mockResolvedValue(null); +describe('Session', () => { + const now = 123456; + const mockEncryptionKey = 'a'.repeat(32); + const encryptContent = (contentToEncrypt: SessionValueContentToEncrypt, aad: string) => + nodeCrypto({ encryptionKey: mockEncryptionKey }).encrypt(JSON.stringify(contentToEncrypt), aad); + + let mockSessionIndex: jest.Mocked>; + let mockSessionCookie: jest.Mocked>; + let session: Session; + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => now); + + let callCount = 0; + jest.spyOn(crypto, 'randomBytes').mockImplementation((num, callback) => { + // We still need _some_ randomness here to distinguish generated bytes for SID and AAD. + const buffer = Buffer.from([++callCount, ...Array(num - 1).keys()]); + if (typeof callback !== 'function') { + return buffer; + } + callback(null, buffer); + }); - await expect(getSessionInfo(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + mockSessionCookie = sessionCookieMock.create(); + mockSessionIndex = sessionIndexMock.create(); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: mockEncryptionKey, + session: { idleTimeout: 123, lifespan: 456 }, + }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); }); -}); -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('#get', () => { + const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); -it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + it('returns `null` if session cookie does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + }); - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + it('clears session value if session is expired because of idle timeout', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now - 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); - await expect( - authenticator.login(request, { provider: { type: 'basic' }, value: {} }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); + it('clears session value if session is expired because of lifespan', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now - 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); + it('clears session value if session cookie does not have corresponding session index value', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue(null); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + }); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); -}); + it('clears session value if session index value content cannot be decrypted', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue(sessionIndexMock.createValue({ content: 'Uh! Oh!' })); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); -it('clears session if it belongs to a different provider.', async () => { - const user = mockAuthenticatedUser(); - const credentials = { username: 'user', password: 'password' }; - const request = httpServerMock.createKibanaRequest(); + it('clears session value if session index value content cannot be decrypted because of wrong AAD', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: 'some-wrong-aad', + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockOptions.session.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, + it('returns session value with decrypted content', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now - 1, + lifespanExpiration: now + 1, + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }); + mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + provider: { name: 'basic1', type: 'basic' }, + sid: 'some-long-sid', + state: 'some-state', + username: 'some-user', + }); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + }); }); - 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 } } }, + describe('#create', () => { + it('creates session value', async () => { + const mockSID = Buffer.from([1, ...Array(255).keys()]).toString('base64'); + const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + sid: mockSID, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + mockSessionIndex.create.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.create(mockRequest, { + username: mockAuthenticatedUser().username, + provider: { type: 'basic', name: 'basic1' }, + state: 'some-state', + }) + ).resolves.toEqual({ + sid: mockSID, + username: 'user', + state: 'some-state', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly creates session index value. + expect(mockSessionIndex.create).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.create).toHaveBeenCalledWith({ + sid: mockSID, + content: + 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCqBD0WihwGK2mc3zjHg70tqUpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMMPFetOkRITDI+NMz7e3JcMofnDboRnvg==', + provider: { name: 'basic1', type: 'basic' }, + usernameHash: '8ac76453d769d4fd14b3f41ad4933f9bd64321972cd002de9b847e117435b08b', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + + // Properly creates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: mockSID, + aad: mockAAD, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + }); }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); + describe('#update', () => { + const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); + + it('returns `null` if there is no corresponding session cookie value', async () => { + mockSessionCookie.get.mockResolvedValue(null); + // To make sure we aren't even calling this method. + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect( + session.update(httpServerMock.createKibanaRequest(), sessionMock.createValue()) + ).resolves.toBeNull(); + }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); + it('returns `null` and clears cookie if there is no corresponding session index value', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + mockSessionIndex.update.mockResolvedValue(null); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.update(mockRequest, sessionMock.createValue())).resolves.toBeNull(); - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: currentDate + 3600 * 24, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); -}); + it('updates session value', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update( + mockRequest, + sessionMock.createValue({ + username: 'new-user', + state: 'new-state', + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ) + ).resolves.toEqual({ + sid: 'some-long-sid', + username: 'new-user', + state: 'new-state', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly updates session index value. + expect(mockSessionIndex.update).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.update).toHaveBeenCalledWith({ + sid: 'some-long-sid', + content: + 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJCt8yPPMsaNAxn7qtLtc57UN967e9FpjmJgEIipe6nD20F47TtNIZnAuzd75zc8TNWvPMgRTzpHnYz7cT9m5ouv2V8TZ+ow==', + provider: { name: 'basic1', type: 'basic' }, + usernameHash: '35133597af273830c3f139c72501e676338f28a39dca8ff62d5c2b8bfba75f69', + tenant: 'some-tenant', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + }); + + // Properly updates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: 'some-long-sid', + aad: mockAAD, + path: '/mock-base-path', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + }); + }); -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 } } }, - }); + it('properly extends session expiration if idle timeout is defined.', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ aad: mockAAD, idleTimeoutExpiration: now + 1 }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ + idleTimeoutExpiration: now + 123, + }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: 123 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update(mockRequest, sessionMock.createValue({ idleTimeoutExpiration: now + 1 })) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + }); - 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, + describe('conditionally updates the session lifespan expiration', () => { + const hr = 1000 * 60 * 60; + async function updateSession( + lifespan: number | null, + oldExpiration: number | null, + newExpiration: number | null + ) { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ aad: mockAAD, lifespanExpiration: oldExpiration }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ lifespanExpiration: newExpiration }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { lifespan } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update( + mockRequest, + sessionMock.createValue({ lifespanExpiration: oldExpiration }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + } + + it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { + await updateSession(hr * 8, 1234, 1234); + }); + + it('does not change a null lifespan expiration when configured to null value.', async () => { + await updateSession(null, null, null); + }); + + it('does change a non-null lifespan expiration when configured to null value.', async () => { + await updateSession(null, 1234, null); + }); + + it('does change a null lifespan expiration when configured to non-null value', async () => { + await updateSession(hr * 8, null, now + hr * 8); + }); + }); }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + describe('#extend', () => { + it('returns `null` if there is no corresponding session cookie value', async () => { + mockSessionCookie.get.mockResolvedValue(null); - 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(); -}); + await expect( + session.extend(httpServerMock.createKibanaRequest(), sessionMock.createValue()) + ).resolves.toBeNull(); -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 } } }, + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: oldExpiration, + it('returns specified session unmodified if neither idle timeout nor lifespan is specified', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + mockSessionIndex.update.mockResolvedValue(sessionIndexMock.createValue()); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.extend(mockRequest, sessionMock.createValue())).resolves.toEqual( + sessionMock.createValue() + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); }); - 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) - ); + it('properly extends session expiration if both idle timeout and lifespan are defined.', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 2, + }) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ idleTimeoutExpiration: now + 1, lifespanExpiration: now + 2 }) + ) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: now + 2 }) + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: now + 2 }) + ); + }); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: newExpiration, + describe('updates the session idle timeout expiration', () => { + beforeEach(() => { + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: 123 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + }); + + it('does not update session index value if idle timeout is below threshold.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 }) + ) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('returns `null` and clears cookie if session index value does not exist.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + mockSessionIndex.update.mockResolvedValue(null); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1 }) + ) + ).resolves.toBeNull(); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); + + it('updates session index value if idle timeout exceeds threshold.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1 }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('updates session index value if idle timeout was not configured before.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.extend(mockRequest, sessionMock.createValue())).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('updates session index value if idle timeout is not configured anymore.', async () => { + const expectedNewExpiration = null; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: null } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend(mockRequest, sessionMock.createValue({ idleTimeoutExpiration: now + 1 })) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); }); - 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); + describe('conditionally updates the session lifespan expiration', () => { + const hr = 1000 * 60 * 60; + async function updateSession( + lifespan: number | null, + oldExpiration: number | null, + newExpiration: number | null + ) { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ lifespanExpiration: oldExpiration }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ lifespanExpiration: newExpiration }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { lifespan } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ lifespanExpiration: oldExpiration }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + + if (oldExpiration === newExpiration) { + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + } else { + // We update session index only when lifespan configuration changes. + if (oldExpiration === null || newExpiration === null) { + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + } else { + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + } + + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + } + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + } + + it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { + await updateSession(hr * 8, 1234, 1234); + }); + + it('does not change a null lifespan expiration when configured to null value.', async () => { + await updateSession(null, null, null); + }); + + it('does change a non-null lifespan expiration when configured to null value.', async () => { + await updateSession(null, 1234, null); + }); + + it('does change a null lifespan expiration when configured to non-null value', async () => { + await updateSession(hr * 8, null, now + hr * 8); + }); + }); }); -}); -it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); + describe('#clear', () => { + it('does not clear anything if session does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + await session.clear(httpServerMock.createKibanaRequest()); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + it('clears both session cookie and session index', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); -}); + const mockRequest = httpServerMock.createKibanaRequest(); + await session.clear(mockRequest); -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' }, - }); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledWith('some-long-sid'); - await expect(authenticator.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - - expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockOptions.session.clear).toHaveBeenCalled(); -}); -*/ + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); }); }); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index ca8147c0fa76d..a23361dadbdff 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -46,11 +46,6 @@ export interface SessionValue { */ 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. @@ -69,14 +64,13 @@ export interface SessionValue { } export interface SessionOptions { - serverBasePath: string; - logger: Logger; - sessionIndex: SessionIndex; - sessionCookie: SessionCookie; - config: Pick; + readonly logger: Logger; + readonly sessionIndex: PublicMethodsOf; + readonly sessionCookie: PublicMethodsOf; + readonly config: Pick; } -interface SessionValueContentToEncrypt { +export interface SessionValueContentToEncrypt { username?: string; state: unknown; } @@ -85,41 +79,37 @@ export class Session { /** * Session timeout in ms. If `null` session will stay active until the browser is closed. */ - readonly #idleTimeout: Duration | null; + private 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. + * Timeout after which idle timeout property is updated in the index. */ - readonly #idleIndexUpdateTimeout: number | null; + private readonly idleIndexUpdateTimeout: number | null; /** * Session max lifespan in ms. If `null` session may live indefinitely. */ - readonly #lifespan: Duration | null; + private readonly lifespan: Duration | null; /** * Used to encrypt and decrypt portion of the session value using configured encryption key. */ - readonly #crypto: Crypto; + private readonly crypto: Crypto; /** * Promise-based version of the NodeJS native `randomBytes`. */ - readonly #randomBytes = promisify(randomBytes); + private readonly randomBytes = promisify(randomBytes); - /** - * Options used to create Session. - */ - readonly #options: Readonly; - - constructor(options: Readonly) { - this.#options = options; - 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 + constructor(private readonly options: Readonly) { + this.crypto = nodeCrypto({ encryptionKey: this.options.config.encryptionKey }); + this.idleTimeout = this.options.config.session.idleTimeout; + this.lifespan = this.options.config.session.lifespan; + + // The timeout after which we update index is two times longer than configured idle timeout + // since index updates are costly and we want to minimize them. + this.idleIndexUpdateTimeout = this.options.config.session.idleTimeout + ? this.options.config.session.idleTimeout.asMilliseconds() * 2 : null; } @@ -129,7 +119,7 @@ export class Session { * @param request Request instance to get session value for. */ async get(request: KibanaRequest) { - const sessionCookieValue = await this.#options.sessionCookie.get(request); + const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { return null; } @@ -140,31 +130,36 @@ export class Session { sessionCookieValue.idleTimeoutExpiration < now) || (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < now) ) { - this.#options.logger.debug('Session has expired and will be invalidated.'); + 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); + const sessionIndexValue = await this.options.sessionIndex.get(sessionCookieValue.sid); if (!sessionIndexValue) { - this.#options.logger.debug( + this.options.logger.debug( 'Session value is not available in the index, session cookie will be invalidated.' ); - await this.clear(request); + await this.options.sessionCookie.clear(request); return null; } + let decryptedContent: SessionValueContentToEncrypt; 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, - }; + decryptedContent = JSON.parse( + (await this.crypto.decrypt(sessionIndexValue.content, sessionCookieValue.aad)) as string + ); } catch (err) { - this.#options.logger.warn('Unable to decrypt session content, session will be invalidated.'); + this.options.logger.warn('Unable to decrypt session content, session will be invalidated.'); await this.clear(request); return null; } + + return { + ...Session.sessionIndexValueToSessionValue(sessionIndexValue, decryptedContent), + // Unlike session index, session cookie contains the most up to date idle timeout expiration. + idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration, + }; } /** @@ -175,35 +170,32 @@ export class Session { async create( request: KibanaRequest, sessionValue: Readonly< - Omit< - SessionValue, - 'sid' | 'idleTimeoutExpiration' | 'lifespanExpiration' | 'path' | 'metadata' - > + Omit > ) { - // 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')), + 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 }; + const { username, state, ...publicSessionValue } = sessionValue; // 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 }); + const sessionIndexValue = await this.options.sessionIndex.create({ + ...publicSessionValue, + ...sessionExpirationInfo, + sid, + usernameHash: username && createHash('sha3-256').update(username).digest('hex'), + content: await this.crypto.encrypt(JSON.stringify({ username, state }), aad), + }); + + await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad }); - this.#options.logger.debug('Successfully created new session.'); + this.options.logger.debug('Successfully created new session.'); - return { ...createdSessionValue, metadata: { index: sessionIndexValue } } as Readonly< - SessionValue - >; + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); } /** @@ -212,44 +204,44 @@ export class Session { * @param sessionValue Session value parameters. */ async update(request: KibanaRequest, sessionValue: Readonly) { - const sessionCookieValue = await this.#options.sessionCookie.get(request); + const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { - throw new Error('Session cannot be update since it doesnt exist.'); + this.options.logger.warn('Session cannot be updated since it does not exist.'); + return null; } const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); - const { metadata, ...sessionValueToUpdate } = sessionValue; - const updatedSessionValue = { - ...sessionValueToUpdate, - ...sessionExpirationInfo, - path: this.#options.serverBasePath, - }; + const { username, state, metadata, ...publicSessionInfo } = sessionValue; // 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({ + const sessionIndexValue = await this.options.sessionIndex.update({ ...sessionValue.metadata.index, - ...(await this.encryptSessionValue(updatedSessionValue, sessionCookieValue.aad)), + ...publicSessionInfo, + ...sessionExpirationInfo, + usernameHash: username && createHash('sha3-256').update(username).digest('hex'), + content: await this.crypto.encrypt( + JSON.stringify({ username, state }), + 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); + 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, { + await this.options.sessionCookie.set(request, { ...sessionCookieValue, ...sessionExpirationInfo, }); - this.#options.logger.debug('Successfully updated existing session.'); + this.options.logger.debug('Successfully updated existing session.'); - return { ...updatedSessionValue, metadata: { index: sessionIndexValue } } as Readonly< - SessionValue - >; + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); } /** @@ -258,9 +250,10 @@ export class Session { * @param sessionValue Session value parameters. */ async extend(request: KibanaRequest, sessionValue: Readonly) { - const sessionCookieValue = await this.#options.sessionCookie.get(request); + const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { - throw new Error('Session cannot be extended since it doesnt exist.'); + this.options.logger.warn('Session cannot be extended since it does not exist.'); + return null; } // We calculate actual expiration values based on the information extracted from the portion of @@ -284,7 +277,7 @@ export class Session { ) { // 1. If idle timeout wasn't configured when session was initially created and is configured // now or vice versa. - this.#options.logger.debug( + this.options.logger.debug( 'Session idle timeout configuration has changed, session index will be updated.' ); updateSessionIndex = true; @@ -296,17 +289,17 @@ export class Session { ) { // 2. If lifespan wasn't configured when session was initially created and is configured now // or vice versa. - this.#options.logger.debug( + this.options.logger.debug( 'Session lifespan configuration has changed, session index will be updated.' ); updateSessionIndex = true; } else if ( - this.#idleIndexUpdateTimeout !== null && - this.#idleIndexUpdateTimeout < + this.idleIndexUpdateTimeout !== null && + this.idleIndexUpdateTimeout < sessionExpirationInfo.idleTimeoutExpiration! - sessionValue.idleTimeoutExpiration! ) { // 3. If idle timeout was updated a while ago. - this.#options.logger.debug( + this.options.logger.debug( 'Session idle timeout stored in the index is too old and will be updated.' ); updateSessionIndex = true; @@ -315,7 +308,7 @@ export class Session { // 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({ + const sessionIndexValue = await this.options.sessionIndex.update({ ...sessionValue.metadata.index, ...sessionExpirationInfo, }); @@ -323,20 +316,20 @@ export class Session { // 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); + 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, { + await this.options.sessionCookie.set(request, { ...sessionCookieValue, ...sessionExpirationInfo, }); - this.#options.logger.debug('Successfully extended existing session.'); + this.options.logger.debug('Successfully extended existing session.'); return { ...sessionValue, ...sessionExpirationInfo } as Readonly; } @@ -346,69 +339,17 @@ export class Session { * @param request Request instance to clear session value for. */ async clear(request: KibanaRequest) { - const sessionCookieValue = await this.#options.sessionCookie.get(request); + const sessionCookieValue = await this.options.sessionCookie.get(request); if (!sessionCookieValue) { - return null; + return; } await Promise.all([ - this.#options.sessionCookie.clear(request), - this.#options.sessionIndex.clear(sessionCookieValue.sid), + 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; - } + this.options.logger.debug('Successfully invalidated existing session.'); } private calculateExpiry( @@ -420,11 +361,25 @@ export class Session { // 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 ? currentLifespanExpiration - : this.#lifespan && now + this.#lifespan.asMilliseconds(); - const idleTimeoutExpiration = this.#idleTimeout && now + this.#idleTimeout.asMilliseconds(); + : this.lifespan && now + this.lifespan.asMilliseconds(); + const idleTimeoutExpiration = this.idleTimeout && now + this.idleTimeout.asMilliseconds(); return { idleTimeoutExpiration, lifespanExpiration }; } + + /** + * Converts value retrieved from the index to the value returned to the API consumers. + * @param sessionIndexValue The value returned from the index. + * @param decryptedContent Decrypted session value content. + */ + private static sessionIndexValueToSessionValue( + sessionIndexValue: Readonly, + { username, state }: SessionValueContentToEncrypt + ): Readonly { + // Extract values that are specific to session index value. + const { usernameHash, content, tenant, ...publicSessionValue } = sessionIndexValue; + return { ...publicSessionValue, username, state, metadata: { index: sessionIndexValue } }; + } } diff --git a/x-pack/plugins/security/server/session_management/session_cookie.mock.ts b/x-pack/plugins/security/server/session_management/session_cookie.mock.ts new file mode 100644 index 0000000000000..026117f227561 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.mock.ts @@ -0,0 +1,24 @@ +/* + * 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 { SessionCookie, SessionCookieValue } from './session_cookie'; + +export const sessionCookieMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }), + + createValue: (sessionValue: Partial = {}): SessionCookieValue => ({ + sid: 'some-long-sid', + aad: 'some-aad', + idleTimeoutExpiration: null, + lifespanExpiration: null, + path: '/mock-base-path', + ...sessionValue, + }), +}; diff --git a/x-pack/plugins/security/server/session_management/session_cookie.test.ts b/x-pack/plugins/security/server/session_management/session_cookie.test.ts index 98d63844063a8..584988d201a22 100644 --- a/x-pack/plugins/security/server/session_management/session_cookie.test.ts +++ b/x-pack/plugins/security/server/session_management/session_cookie.test.ts @@ -13,7 +13,7 @@ import { sessionStorageMock, httpServerMock, } from '../../../../../src/core/server/mocks'; -import { sessionMock } from './session.mock'; +import { sessionCookieMock } from './session_cookie.mock'; describe('Session cookie', () => { let sessionCookieOptions: SessionCookieOptions; @@ -62,27 +62,25 @@ describe('Session cookie', () => { ] = (sessionCookieOptions.createCookieSessionStorageFactory as jest.Mock).mock.calls; expect( - validate( - sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }) - ) + validate(sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath })) ).toEqual({ isValid: true }); expect( validate([ - sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), - sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), ]) ).toEqual({ isValid: true }); - expect(validate(sessionMock.createSessionCookieValue({ path: '/some-old-path' }))).toEqual({ + expect(validate(sessionCookieMock.createValue({ path: '/some-old-path' }))).toEqual({ isValid: false, path: '/some-old-path', }); expect( validate([ - sessionMock.createSessionCookieValue({ path: sessionCookieOptions.serverBasePath }), - sessionMock.createSessionCookieValue({ path: '/some-old-path' }), + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), + sessionCookieMock.createValue({ path: '/some-old-path' }), ]) ).toEqual({ isValid: false, path: '/some-old-path' }); }); @@ -100,7 +98,7 @@ describe('Session cookie', () => { }); it('returns value if session is in compatible format', async () => { - const sessionValue = sessionMock.createSessionCookieValue(); + const sessionValue = sessionCookieMock.createValue(); mockSessionStorage.get.mockResolvedValue(sessionValue); const request = httpServerMock.createKibanaRequest(); @@ -111,7 +109,7 @@ describe('Session cookie', () => { }); it('returns `null` and clears session value if it is in incompatible format', async () => { - const invalidValue = sessionMock.createSessionCookieValue(); + const invalidValue = sessionCookieMock.createValue(); delete invalidValue.sid; mockSessionStorage.get.mockResolvedValue(invalidValue); @@ -126,14 +124,17 @@ describe('Session cookie', () => { describe('#set', () => { it('properly sets value in the session storage', async () => { - const sessionValue = sessionMock.createSessionCookieValue(); + const sessionValue = sessionCookieMock.createValue(); const request = httpServerMock.createKibanaRequest(); await sessionCookie.set(request, sessionValue); expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith(sessionValue); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...sessionValue, + path: '/mock-base-path', + }); }); }); diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts index 565a0b1887ec0..62cd1a69c09f2 100644 --- a/x-pack/plugins/security/server/session_management/session_cookie.ts +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -57,12 +57,19 @@ export class SessionCookie { /** * Promise containing initialized cookie session storage factory. */ - readonly #cookieSessionValueStorage: Promise>>; + private readonly cookieSessionValueStorage: Promise< + SessionStorageFactory> + >; /** * Session cookie logger. */ - readonly #logger: Logger; + private readonly logger: Logger; + + /** + * Base path of the Kibana server instance. + */ + private readonly serverBasePath: string; constructor({ config, @@ -70,8 +77,10 @@ export class SessionCookie { logger, serverBasePath, }: Readonly) { - this.#logger = logger; - this.#cookieSessionValueStorage = createCookieSessionStorageFactory({ + this.logger = logger; + this.serverBasePath = serverBasePath; + + this.cookieSessionValueStorage = createCookieSessionStorageFactory({ encryptionKey: config.encryptionKey, isSecure: config.secureCookies, name: config.cookieName, @@ -84,7 +93,7 @@ export class SessionCookie { ).find((sess) => sess.path !== undefined && sess.path !== serverBasePath); if (invalidSessionValue) { - this.#logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); + this.logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); return { isValid: false, path: invalidSessionValue.path }; } @@ -98,7 +107,7 @@ export class SessionCookie { * @param request Request instance to get session value for. */ async get(request: KibanaRequest) { - const sessionStorage = (await this.#cookieSessionValueStorage).asScoped(request); + 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 @@ -116,8 +125,10 @@ export class SessionCookie { * @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); + async set(request: KibanaRequest, sessionValue: Readonly>) { + (await this.cookieSessionValueStorage) + .asScoped(request) + .set({ ...sessionValue, path: this.serverBasePath }); } /** @@ -125,7 +136,7 @@ export class SessionCookie { * @param request Request instance to clear session value for. */ async clear(request: KibanaRequest) { - (await this.#cookieSessionValueStorage).asScoped(request).clear(); + (await this.cookieSessionValueStorage).asScoped(request).clear(); } /** diff --git a/x-pack/plugins/security/server/session_management/session_index.mock.ts b/x-pack/plugins/security/server/session_management/session_index.mock.ts new file mode 100644 index 0000000000000..e26df4ecb4ea7 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.mock.ts @@ -0,0 +1,30 @@ +/* + * 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 { SessionIndex, SessionIndexValue } from './session_index'; + +export const sessionIndexMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + clear: jest.fn(), + initialize: jest.fn(), + cleanUp: jest.fn(), + }), + + createValue: (sessionValue: Partial = {}): SessionIndexValue => ({ + sid: 'some-long-sid', + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + tenant: 'some-tenant', + content: 'some-encrypted-content', + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + ...sessionValue, + }), +}; diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts new file mode 100644 index 0000000000000..e5189a6e961bc --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -0,0 +1,534 @@ +/* + * 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 } from '../../../../../src/core/server'; +import { ConfigSchema, createConfig } from '../config'; +import { + SESSION_INDEX_ALIAS, + SESSION_INDEX_NAME, + SESSION_INDEX_TEMPLATE, + SessionIndex, +} from './session_index'; + +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { sessionIndexMock } from './session_index.mock'; + +describe('Session index', () => { + let mockClusterClient: jest.Mocked; + let sessionIndex: SessionIndex; + beforeEach(() => { + mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + const sessionIndexOptions = { + logger: loggingSystemMock.createLogger(), + tenant: 'some-tenant', + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + clusterClient: mockClusterClient, + }; + + sessionIndex = new SessionIndex(sessionIndexOptions); + }); + + describe('#initialize', () => { + function assertExistenceChecksPerformed() { + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + name: SESSION_INDEX_TEMPLATE.name, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { + index: SESSION_INDEX_TEMPLATE.template.index_patterns, + }); + } + + it('debounces initialize calls', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return true; + } + + throw new Error('Unexpected call'); + }); + + await Promise.all([ + sessionIndex.initialize(), + sessionIndex.initialize(), + sessionIndex.initialize(), + sessionIndex.initialize(), + ]); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + assertExistenceChecksPerformed(); + }); + + it('creates neither index template nor index if they exist', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return true; + } + + throw new Error('Unexpected call'); + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + assertExistenceChecksPerformed(); + }); + + it('creates both index template and index if they do not exist', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return false; + } + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + name: SESSION_INDEX_TEMPLATE.name, + body: SESSION_INDEX_TEMPLATE.template, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + index: SESSION_INDEX_TEMPLATE.template.index_patterns, + }); + }); + + it('creates only index template if it does not exist even if index exists', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return false; + } + + if (method === 'indices.exists') { + return true; + } + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + name: SESSION_INDEX_TEMPLATE.name, + body: SESSION_INDEX_TEMPLATE.template, + }); + }); + + it('creates only index if it does not exist even if index template exists', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return true; + } + + if (method === 'indices.exists') { + return false; + } + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + index: SESSION_INDEX_TEMPLATE.template.index_patterns, + }); + }); + + it('does not fail if tries to create index when it exists already', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return true; + } + + if (method === 'indices.exists') { + return false; + } + + if (method === 'indices.create') { + // eslint-disable-next-line no-throw-literal + throw { body: { error: { type: 'resource_already_exists_exception' } } }; + } + }); + + await sessionIndex.initialize(); + }); + }); + + describe('cleanUp', () => { + const now = 123456; + beforeEach(() => { + mockClusterClient.callAsInternalUser.mockResolvedValue({}); + jest.spyOn(Date, 'now').mockImplementation(() => now); + }); + + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); + }); + + it('when neither `lifespan` nor `idleTimeout` is configured', async () => { + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + filter: { term: { tenant: 'some-tenant' } }, + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { range: { idleTimeoutExpiration: { lte: now } } }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + }); + + it('when only `lifespan` is configured', async () => { + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + tenant: 'some-tenant', + config: createConfig( + ConfigSchema.validate({ session: { lifespan: 456 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + filter: { term: { tenant: 'some-tenant' } }, + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, + { range: { idleTimeoutExpiration: { lte: now } } }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + }); + + it('when only `idleTimeout` is configured', async () => { + const idleTimeout = 123; + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + tenant: 'some-tenant', + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + filter: { term: { tenant: 'some-tenant' } }, + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + }); + + it('when both `lifespan` and `idleTimeout` are configured', async () => { + const idleTimeout = 123; + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + tenant: 'some-tenant', + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout, lifespan: 456 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + filter: { term: { tenant: 'some-tenant' } }, + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + }); + }); + + describe('#get', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.get('some-sid')).rejects.toBe(failureReason); + }); + + it('returns `null` if index is not found', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ status: 404 }); + + await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); + }); + + it('returns `null` if session index value document is not found', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + found: false, + status: 200, + }); + + await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); + }); + + it('properly returns session index value', async () => { + const indexDocumentSource = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 123, + lifespanExpiration: null, + tenant: 'some-tenant', + content: 'some-encrypted-content', + }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ + found: true, + status: 200, + _source: indexDocumentSource, + _primary_term: 1, + _seq_no: 456, + }); + + await expect(sessionIndex.get('some-sid')).resolves.toEqual({ + ...indexDocumentSource, + sid: 'some-sid', + metadata: { primaryTerm: 1, sequenceNumber: 456 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('get', { + id: 'some-sid', + ignore: [404], + index: SESSION_INDEX_ALIAS, + }); + }); + }); + + describe('#create', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect( + sessionIndex.create({ + sid: 'some-long-sid', + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }) + ).rejects.toBe(failureReason); + }); + + it('properly stores session value in the index', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + _primary_term: 321, + _seq_no: 654, + }); + + const sid = 'some-long-sid'; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.create({ sid, ...sessionValue })).resolves.toEqual({ + ...sessionValue, + sid, + tenant: 'some-tenant', + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('create', { + id: sid, + index: SESSION_INDEX_NAME, + body: { ...sessionValue, tenant: 'some-tenant' }, + refresh: 'wait_for', + }); + }); + }); + + describe('#update', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.update(sessionIndexMock.createValue())).rejects.toBe(failureReason); + }); + + it('refetches latest session value if update fails due to conflict', async () => { + const latestSessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + tenant: 'some-tenant', + content: 'some-updated-encrypted-content', + }; + + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'get') { + return { + found: true, + status: 200, + _source: latestSessionValue, + _primary_term: 321, + _seq_no: 654, + }; + } + + if (method === 'index') { + return { status: 409 }; + } + }); + + const sid = 'some-long-sid'; + const metadata = { primaryTerm: 123, sequenceNumber: 456 }; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + tenant: 'some-other-tenant', + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.update({ sid, metadata, ...sessionValue })).resolves.toEqual({ + ...latestSessionValue, + sid, + tenant: 'some-tenant', + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { + id: sid, + index: SESSION_INDEX_ALIAS, + body: { ...sessionValue, tenant: 'some-tenant' }, + ifSeqNo: 456, + ifPrimaryTerm: 123, + refresh: 'wait_for', + ignore: [409], + }); + }); + + it('properly stores session value in the index', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + _primary_term: 321, + _seq_no: 654, + status: 200, + }); + + const sid = 'some-long-sid'; + const metadata = { primaryTerm: 123, sequenceNumber: 456 }; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + tenant: 'some-tenant', + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.update({ sid, metadata, ...sessionValue })).resolves.toEqual({ + ...sessionValue, + sid, + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { + id: sid, + index: SESSION_INDEX_ALIAS, + body: sessionValue, + ifSeqNo: 456, + ifPrimaryTerm: 123, + refresh: 'wait_for', + ignore: [409], + }); + }); + }); + + describe('#clear', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.clear('some-long-sid')).rejects.toBe(failureReason); + }); + + it('properly removes session value from the index', async () => { + await sessionIndex.clear('some-long-sid'); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('delete', { + id: 'some-long-sid', + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [404], + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index 3244f9e340787..bbd0b86413158 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -9,10 +9,10 @@ import { AuthenticationProvider } from '../../common/types'; import { ConfigType } from '../config'; export interface SessionIndexOptions { - clusterClient: ILegacyClusterClient; - serverBasePath: string; - config: Pick; - logger: Logger; + readonly clusterClient: ILegacyClusterClient; + readonly tenant: string; + readonly config: Pick; + readonly logger: Logger; } /** @@ -23,17 +23,17 @@ 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'; +export 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}`; +export 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 = { +export const SESSION_INDEX_TEMPLATE = { name: `${SESSION_INDEX_ALIAS}_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`, version: SESSION_INDEX_TEMPLATE_VERSION, template: { @@ -52,9 +52,9 @@ const SESSION_INDEX_TEMPLATE = { mappings: { dynamic: 'strict', properties: { - username_hash: { type: 'keyword' }, + usernameHash: { type: 'keyword' }, provider: { properties: { name: { type: 'keyword' }, type: { type: 'keyword' } } }, - path: { type: 'keyword' }, + tenant: { type: 'keyword' }, idleTimeoutExpiration: { type: 'date' }, lifespanExpiration: { type: 'date' }, accessAgreementAcknowledged: { type: 'boolean' }, @@ -74,11 +74,16 @@ export interface SessionIndexValue { */ sid: string; + /** + * Name of the current Kibana tenant. + */ + tenant: 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; + usernameHash?: string; /** * Name and type of the provider this session belongs to. @@ -97,11 +102,6 @@ export interface SessionIndexValue { */ lifespanExpiration: number | null; - /** - * Kibana server base path the session was created for. - */ - path: string; - /** * Indicates whether user acknowledged access agreement or not. */ @@ -136,15 +136,9 @@ interface SessionIndexValueMetadata { 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. + * during regular cleanup routine. */ - readonly #idleIndexCleanupTimeout: number | null; - - /** - * Options used to create Session index. - */ - readonly #options: Readonly; + private readonly idleIndexCleanupTimeout: number | null; /** * Promise that tracks session index initialization process. We'll need to get rid of this as soon @@ -153,10 +147,12 @@ export class SessionIndex { */ private indexInitialization?: Promise; - constructor(options: Readonly) { - this.#options = options; - this.#idleIndexCleanupTimeout = this.#options.config.session.idleTimeout - ? this.#options.config.session.idleTimeout.asMilliseconds() * 3 + constructor(private readonly options: Readonly) { + // This timeout is intentionally larger than the `idleIndexUpdateTimeout` (idleTimeout * 2) + // configured in `Session` to be sure that the session value is definitely expired and may be + // safely cleaned up. + this.idleIndexCleanupTimeout = this.options.config.session.idleTimeout + ? this.options.config.session.idleTimeout.asMilliseconds() * 3 : null; } @@ -167,7 +163,7 @@ export class SessionIndex { */ async get(sid: string) { try { - const response = await this.#options.clusterClient.callAsInternalUser('get', { + const response = await this.options.clusterClient.callAsInternalUser('get', { id: sid, ignore: [404], index: SESSION_INDEX_ALIAS, @@ -176,17 +172,17 @@ export class SessionIndex { 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.'); + this.options.logger.debug('Cannot find session value with the specified ID.'); return null; } return { - sid, ...response._source, + sid, metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, } as Readonly; } catch (err) { - this.#options.logger.error(`Failed to retrieve session value: ${err.message}`); + this.options.logger.error(`Failed to retrieve session value: ${err.message}`); throw err; } } @@ -195,9 +191,9 @@ export class SessionIndex { * Creates a new document for the specified session value. * @param sessionValue Session index value. */ - async create(sessionValue: Readonly>) { + async create(sessionValue: Readonly>) { if (this.indexInitialization) { - this.#options.logger.warn( + this.options.logger.warn( 'Attempted to create a new session while session index is initializing.' ); await this.indexInitialization; @@ -208,20 +204,24 @@ export class SessionIndex { const { _primary_term: primaryTerm, _seq_no: sequenceNumber, - } = await this.#options.clusterClient.callAsInternalUser('create', { + } = 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, + body: { ...sessionValueToStore, tenant: this.options.tenant }, refresh: 'wait_for', }); - return { ...sessionValue, metadata: { primaryTerm, sequenceNumber } } as SessionIndexValue; + return { + ...sessionValue, + tenant: this.options.tenant, + metadata: { primaryTerm, sequenceNumber }, + } as SessionIndexValue; } catch (err) { - this.#options.logger.error(`Failed to create session value: ${err.message}`); + this.options.logger.error(`Failed to create session value: ${err.message}`); throw err; } } @@ -233,10 +233,10 @@ export class SessionIndex { async update(sessionValue: Readonly) { const { sid, metadata, ...sessionValueToStore } = sessionValue; try { - const response = await this.#options.clusterClient.callAsInternalUser('index', { + const response = await this.options.clusterClient.callAsInternalUser('index', { id: sid, index: SESSION_INDEX_ALIAS, - body: sessionValueToStore, + body: { ...sessionValueToStore, tenant: this.options.tenant }, ifSeqNo: metadata.sequenceNumber, ifPrimaryTerm: metadata.primaryTerm, refresh: 'wait_for', @@ -248,66 +248,39 @@ export class SessionIndex { // 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.' + this.options.logger.debug( + 'Cannot update session value due to conflict, session either does not exist or was already updated.' ); return await this.get(sid); } return { ...sessionValue, + tenant: this.options.tenant, metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, } as SessionIndexValue; } catch (err) { - this.#options.logger.error(`Failed to update session value: ${err.message}`); + 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. + * Clears session value with the specified ID. * @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', { + // We don't specify primary term and sequence number as delete should always take precedence + // over any updates that could happen in the meantime. + await this.options.clusterClient.callAsInternalUser('delete', { + id: sid, index: SESSION_INDEX_ALIAS, refresh: 'wait_for', - ignore: [409, 404], - body: { conflicts: 'proceed', query: { bool: { should: deleteQueries } } }, + ignore: [404], }); } catch (err) { - this.#options.logger.error(`Failed to clear session value: ${err.message}`); + this.options.logger.error(`Failed to clear session value: ${err.message}`); throw err; } } @@ -320,16 +293,16 @@ export class SessionIndex { return await this.indexInitialization; } - this.indexInitialization = new Promise(async (resolve) => { + return (this.indexInitialization = new Promise(async (resolve) => { // Check if required index template exists. let indexTemplateExists = false; try { - indexTemplateExists = await this.#options.clusterClient.callAsInternalUser( + indexTemplateExists = await this.options.clusterClient.callAsInternalUser( 'indices.existsTemplate', { name: SESSION_INDEX_TEMPLATE.name } ); } catch (err) { - this.#options.logger.error( + this.options.logger.error( `Failed to check if session index template exists: ${err.message}` ); throw err; @@ -337,16 +310,16 @@ export class SessionIndex { // Create index template if it doesn't exist. if (indexTemplateExists) { - this.#options.logger.debug('Session index template already exists.'); + this.options.logger.debug('Session index template already exists.'); } else { try { - await this.#options.clusterClient.callAsInternalUser('indices.putTemplate', { + 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.'); + this.options.logger.debug('Successfully created session index template.'); } catch (err) { - this.#options.logger.error(`Failed to create session index template: ${err.message}`); + this.options.logger.error(`Failed to create session index template: ${err.message}`); throw err; } } @@ -355,32 +328,91 @@ export class SessionIndex { // always enabled, so we create session index explicitly. let indexExists = false; try { - indexExists = await this.#options.clusterClient.callAsInternalUser('indices.exists', { + 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}`); + 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.'); + this.options.logger.debug('Session index already exists.'); } else { try { - await this.#options.clusterClient.callAsInternalUser('indices.create', { + await this.options.clusterClient.callAsInternalUser('indices.create', { index: SESSION_INDEX_NAME, }); - this.#options.logger.debug('Successfully created session index.'); + this.options.logger.debug('Successfully created session index.'); } catch (err) { - this.#options.logger.error(`Failed to create session index: ${err.message}`); - throw err; + // There can be a race condition if index is created by another Kibana instance. + if (err?.body?.error?.type === 'resource_already_exists_exception') { + this.options.logger.debug('Session index already exists.'); + } else { + this.options.logger.error(`Failed to create session index: ${err.message}`); + throw err; + } } } // Notify any consumers that are awaiting on this promise and immediately reset it. resolve(); this.indexInitialization = undefined; - }); + })); + } + + /** + * Trigger a removal of any outdated session values. + */ + async cleanUp() { + const now = Date.now(); + + // Always try to delete sessions with expired lifespan (even if it's not configured right now). + const deleteQueries: object[] = [{ range: { lifespanExpiration: { lte: now } } }]; + + // 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 } } }); + } + + try { + const response = await this.options.clusterClient.callAsInternalUser('deleteByQuery', { + index: SESSION_INDEX_ALIAS, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + filter: { term: { tenant: this.options.tenant } }, + should: deleteQueries, + minimum_should_match: 1, + }, + }, + }, + }); + + if (typeof response.deleted === 'number') { + this.options.logger.debug( + `Cleaned up ${response.deleted} invalid or expired session values.` + ); + } + } catch (err) { + this.options.logger.error(`Failed to clean up sessions: ${err.message}`); + throw err; + } } } diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index d12c2d8ebaf53..aed276725d77e 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -7,16 +7,21 @@ import { Subject } from 'rxjs'; import { ConfigSchema, createConfig } from '../config'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; -import { SessionManagementService } from './session_management_service'; +import { + SessionManagementService, + SESSION_INDEX_CLEANUP_TASK_NAME, +} from './session_management_service'; import { Session } from './session'; +import { SessionIndex } from './session_index'; +import { nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, elasticsearchServiceMock, loggingSystemMock, } from '../../../../../src/core/server/mocks'; -import { nextTick } from 'test_utils/enzyme_helpers'; -import { SessionIndex } from './session_index'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; +import { TaskManagerStartContract } from '../../../task_manager/server'; describe('SessionManagementService', () => { let service: SessionManagementService; @@ -27,6 +32,7 @@ describe('SessionManagementService', () => { describe('setup()', () => { it('exposes proper contract', () => { const mockCoreSetup = coreMock.createSetup(); + const mockTaskManager = taskManagerMock.createSetup(); expect( service.setup({ @@ -35,16 +41,64 @@ describe('SessionManagementService', () => { config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), + kibanaIndexName: '.kibana', + taskManager: mockTaskManager, }) ).toEqual({ session: expect.any(Session) }); + + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ + [SESSION_INDEX_CLEANUP_TASK_NAME]: { + title: 'Cleanup expired or invalid sessions', + type: SESSION_INDEX_CLEANUP_TASK_NAME, + createTaskRunner: expect.any(Function), + }, + }); + }); + + it('registers proper session index cleanup task runner', () => { + const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); + const mockTaskManager = taskManagerMock.createSetup(); + + const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockClusterClient.callAsInternalUser.mockResolvedValue({}); + service.setup({ + clusterClient: mockClusterClient, + http: coreMock.createSetup().http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + kibanaIndexName: '.kibana', + taskManager: mockTaskManager, + }); + + const [ + [ + { + [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, + }, + ], + ] = mockTaskManager.registerTaskDefinitions.mock.calls; + expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); + + const runner = createTaskRunner({} as any); + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); + + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); }); }); describe('start()', () => { let mockSessionIndexInitialize: jest.SpyInstance; + let mockTaskManager: jest.Mocked; beforeEach(() => { mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + mockTaskManager = taskManagerMock.createStart(); + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + const mockCoreSetup = coreMock.createSetup(); service.setup({ clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), @@ -52,6 +106,8 @@ describe('SessionManagementService', () => { config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), + kibanaIndexName: '.kibana', + taskManager: taskManagerMock.createSetup(), }); }); @@ -61,12 +117,14 @@ describe('SessionManagementService', () => { it('exposes proper contract', () => { const mockStatusSubject = new Subject(); - expect(service.start({ online$: mockStatusSubject.asObservable() })).toBeUndefined(); + expect( + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }) + ).toBeUndefined(); }); it('initializes session index when Elasticsearch goes online', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable() }); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); // ES isn't online yet. expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); @@ -85,7 +143,7 @@ describe('SessionManagementService', () => { it('schedules retry if index initialization fails', async () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable() }); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); mockSessionIndexInitialize.mockRejectedValue(new Error('ugh :/')); @@ -109,13 +167,32 @@ describe('SessionManagementService', () => { expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); expect(mockScheduleRetry).toHaveBeenCalledTimes(2); }); + + it('schedules session index cleanup task', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + }); }); describe('stop()', () => { let mockSessionIndexInitialize: jest.SpyInstance; + let mockTaskManager: jest.Mocked; beforeEach(() => { mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + mockTaskManager = taskManagerMock.createStart(); + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + const mockCoreSetup = coreMock.createSetup(); service.setup({ clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), @@ -123,6 +200,8 @@ describe('SessionManagementService', () => { config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), + kibanaIndexName: '.kibana', + taskManager: taskManagerMock.createSetup(), }); }); @@ -132,7 +211,7 @@ describe('SessionManagementService', () => { it('properly unsubscribes from status updates', () => { const mockStatusSubject = new Subject(); - service.start({ online$: mockStatusSubject.asObservable() }); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); service.stop(); 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 index e3994e2d4026b..07df578d2bc7e 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -5,7 +5,9 @@ */ import { Observable, Subscription } from 'rxjs'; +import { createHash } from 'crypto'; import { HttpServiceSetup, ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { TaskManagerSetupContract, TaskManagerStartContract } from '../../../task_manager/server'; import { ConfigType } from '../config'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; import { SessionCookie } from './session_cookie'; @@ -16,74 +18,105 @@ export interface SessionManagementServiceSetupParams { readonly http: Pick; readonly config: ConfigType; readonly clusterClient: ILegacyClusterClient; + readonly kibanaIndexName: string; + readonly taskManager: TaskManagerSetupContract; } export interface SessionManagementServiceStartParams { readonly online$: Observable; + readonly taskManager: TaskManagerStartContract; } export interface SessionManagementServiceSetup { readonly session: Session; } +/** + * Name of the task that is periodically run and performs session index cleanup. + */ +export const SESSION_INDEX_CLEANUP_TASK_NAME = 'session_cleanup'; + /** * Service responsible for the user session management. */ export class SessionManagementService { - readonly #logger: Logger; - #statusSubscription?: Subscription; - #sessionIndex!: SessionIndex; + private statusSubscription?: Subscription; + private sessionIndex!: SessionIndex; + private config!: ConfigType; - constructor(logger: Logger) { - this.#logger = logger; - } + constructor(private readonly logger: Logger) {} setup({ config, clusterClient, http, + kibanaIndexName, + taskManager, }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { - const serverBasePath = http.basePath.serverBasePath || '/'; + this.config = config; const sessionCookie = new SessionCookie({ config, createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, - serverBasePath, - logger: this.#logger.get('cookie'), + serverBasePath: http.basePath.serverBasePath || '/', + logger: this.logger.get('cookie'), }); - this.#sessionIndex = new SessionIndex({ + this.sessionIndex = new SessionIndex({ config, clusterClient, - serverBasePath, - logger: this.#logger.get('index'), + // Unique identifier of Kibana "tenant" based on the .kibana index name it relies on. + tenant: createHash('sha3-256').update(kibanaIndexName).digest('hex'), + logger: this.logger.get('index'), + }); + + // Register task that will perform periodic session index cleanup. + taskManager.registerTaskDefinitions({ + [SESSION_INDEX_CLEANUP_TASK_NAME]: { + title: 'Cleanup expired or invalid sessions', + type: SESSION_INDEX_CLEANUP_TASK_NAME, + createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), + }, }); return { session: new Session({ - serverBasePath, - logger: this.#logger, + logger: this.logger, sessionCookie, - sessionIndex: this.#sessionIndex, + sessionIndex: this.sessionIndex, config, }), }; } - start({ online$ }: SessionManagementServiceStartParams) { - this.#statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { + start({ online$, taskManager }: SessionManagementServiceStartParams) { + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { try { - await this.#sessionIndex.initialize(); + await this.sessionIndex.initialize(); } catch (err) { scheduleRetry(); } }); + + taskManager + .ensureScheduled({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: `${this.config.session.cleanupInterval.asSeconds()}s` }, + params: {}, + state: {}, + }) + .then( + () => this.logger.debug('Successfully scheduled session index cleanup task.'), + (err) => this.logger.error(`Failed to register session index cleanup task: ${err.message}`) + ); } stop() { - if (this.#statusSubscription !== undefined) { - this.#statusSubscription.unsubscribe(); - this.#statusSubscription = undefined; + if (this.statusSubscription !== undefined) { + this.statusSubscription.unsubscribe(); + this.statusSubscription = undefined; } } } From 71339910147c0592ff23e2f7f61f774d7c2f01d8 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 16 Jul 2020 07:31:36 +0200 Subject: [PATCH 07/28] Review#1: manually fix Jest snapshots as they are not updated by Jest for some reason. --- x-pack/plugins/security/server/config.test.ts | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index c7e86b800fbe5..c4b1e6a22111a 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -361,10 +361,10 @@ describe('config schema', () => { authc: { providers: { basic: { basic1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" +`); }); it('cannot be hidden from selector', () => { @@ -375,10 +375,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." +`); }); it('can have only provider of this type', () => { @@ -387,10 +387,10 @@ describe('config schema', () => { authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." +`); }); it('can be successfully validated', () => { @@ -421,10 +421,10 @@ describe('config schema', () => { authc: { providers: { token: { token1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" +`); }); it('cannot be hidden from selector', () => { @@ -435,10 +435,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." +`); }); it('can have only provider of this type', () => { @@ -447,10 +447,10 @@ describe('config schema', () => { authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.token]: Only one \\"token\\" provider can be configured." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." +`); }); it('can be successfully validated', () => { @@ -481,10 +481,10 @@ describe('config schema', () => { authc: { providers: { pki: { pki1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" +`); }); it('can have only provider of this type', () => { @@ -493,10 +493,10 @@ describe('config schema', () => { authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." +`); }); it('can be successfully validated', () => { @@ -525,10 +525,10 @@ describe('config schema', () => { authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" +`); }); it('can have only provider of this type', () => { @@ -539,10 +539,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." +`); }); it('can be successfully validated', () => { @@ -571,10 +571,10 @@ describe('config schema', () => { authc: { providers: { oidc: { oidc1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" +`); }); it('requires `realm`', () => { @@ -583,10 +583,10 @@ describe('config schema', () => { authc: { providers: { oidc: { oidc1: { order: 0 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" +`); }); it('can be successfully validated', () => { @@ -626,10 +626,10 @@ describe('config schema', () => { authc: { providers: { saml: { saml1: { enabled: true } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" +`); }); it('requires `realm`', () => { @@ -638,10 +638,10 @@ describe('config schema', () => { authc: { providers: { saml: { saml1: { order: 0 } } } }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" +`); }); it('can be successfully validated', () => { @@ -704,10 +704,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" +`); }); it('`order` should be unique across all provider types', () => { @@ -724,10 +724,10 @@ describe('config schema', () => { }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[authc.providers]: types that failed validation: - - [authc.providers.0]: expected value of type [array] but got [Object] - - [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" - `); +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" +`); }); it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { From 21bb8056c9cc142980bd0bfd2880d9ce831d0ed2 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 16 Jul 2020 17:21:29 +0200 Subject: [PATCH 08/28] Add basic docs and session integration tests. --- .github/CODEOWNERS | 11 +++ docs/settings/security-settings.asciidoc | 14 ++++ .../session_management/session_index.ts | 2 +- x-pack/scripts/functional_tests.js | 2 + .../ftr_provider_context.d.ts | 10 +++ .../test/security_api_integration/services.ts | 14 ++++ .../session_idle.config.ts | 42 +++++++++++ .../session_lifespan.config.ts | 43 ++++++++++++ .../tests/session_idle/cleanup.ts | 69 +++++++++++++++++++ .../tests/session_idle/extension.ts} | 0 .../tests/session_idle/index.ts | 16 +++++ .../tests/session_lifespan/cleanup.ts | 69 +++++++++++++++++++ .../tests/session_lifespan/index.ts | 15 ++++ 13 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/security_api_integration/ftr_provider_context.d.ts create mode 100644 x-pack/test/security_api_integration/services.ts create mode 100644 x-pack/test/security_api_integration/session_idle.config.ts create mode 100644 x-pack/test/security_api_integration/session_lifespan.config.ts create mode 100644 x-pack/test/security_api_integration/tests/session_idle/cleanup.ts rename x-pack/test/{api_integration/apis/security/session.ts => security_api_integration/tests/session_idle/extension.ts} (100%) create mode 100644 x-pack/test/security_api_integration/tests/session_idle/index.ts create mode 100644 x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts create mode 100644 x-pack/test/security_api_integration/tests/session_lifespan/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2ad82ded6cb38..a6e5ecbfb7693 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -165,6 +165,17 @@ /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security /x-pack/test/api_integration/apis/security/ @elastic/kibana-security +/x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security +/x-pack/test/functional/apps/security/ @elastic/kibana-security +/x-pack/test/kerberos_api_integration/ @elastic/kibana-security +/x-pack/test/login_selector_api_integration/ @elastic/kibana-security +/x-pack/test/oidc_api_integration/ @elastic/kibana-security +/x-pack/test/pki_api_integration/ @elastic/kibana-security +/x-pack/test/saml_api_integration/ @elastic/kibana-security +/x-pack/test/security_api_integration/ @elastic/kibana-security +/x-pack/test/security_functional/ @elastic/kibana-security +/x-pack/test/spaces_api_integration/ @elastic/kibana-security +/x-pack/test/token_api_integration/ @elastic/kibana-security # Kibana Localization /src/dev/i18n/ @elastic/kibana-localization diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index dc7f585f3e4c3..83a76988a7c2f 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -237,6 +237,20 @@ string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). [cols="2*<"] |=== +| `xpack.security.session.cleanupInterval` +| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default this value is 1 hour. The minimum value is 1 minute. + +|=== + +[TIP] +============ +The format is a +string of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). +============ + +[cols="2*<"] +|=== + | `xpack.security.loginAssistanceMessage` | Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index bbd0b86413158..ba0f8f38bc17f 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -405,7 +405,7 @@ export class SessionIndex { }, }); - if (typeof response.deleted === 'number') { + if (response.deleted > 0) { this.options.logger.debug( `Cleaned up ${response.deleted} invalid or expired session values.` ); diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 4dfc3c48668fc..4a9959b6ae558 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -28,6 +28,8 @@ const onlyNotInCoverageTests = [ require.resolve('../test/kerberos_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/security_api_integration/session_idle.config.ts'), + require.resolve('../test/security_api_integration/session_lifespan.config.ts'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), diff --git a/x-pack/test/security_api_integration/ftr_provider_context.d.ts b/x-pack/test/security_api_integration/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..227a19df685fb --- /dev/null +++ b/x-pack/test/security_api_integration/ftr_provider_context.d.ts @@ -0,0 +1,10 @@ +/* + * 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 { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/security_api_integration/services.ts b/x-pack/test/security_api_integration/services.ts new file mode 100644 index 0000000000000..e2abfa71451bc --- /dev/null +++ b/x-pack/test/security_api_integration/services.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 { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + legacyEs: apiIntegrationServices.legacyEs, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/security_api_integration/session_idle.config.ts b/x-pack/test/security_api_integration/session_idle.config.ts new file mode 100644 index 0000000000000..1f1a38e01e164 --- /dev/null +++ b/x-pack/test/security_api_integration/session_idle.config.ts @@ -0,0 +1,42 @@ +/* + * 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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// 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 kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [resolve(__dirname, './tests/session_idle')], + + services: { + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.security.session.idleTimeout=15s', + '--xpack.security.session.cleanupInterval=60s', + ], + }, + + junit: { + reportName: 'Chrome X-Pack Security API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_api_integration/session_lifespan.config.ts b/x-pack/test/security_api_integration/session_lifespan.config.ts new file mode 100644 index 0000000000000..10c0ebbb34601 --- /dev/null +++ b/x-pack/test/security_api_integration/session_lifespan.config.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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// 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 kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [resolve(__dirname, './tests/session_lifespan')], + + services: { + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.security.session.lifespan=20s', + '--xpack.security.session.cleanupInterval=60s', + `--config=/projects/elastic/master/kibana/config/kibana.dev.test.yml`, + ], + }, + + junit: { + reportName: 'Chrome X-Pack Security API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts new file mode 100644 index 0000000000000..c7728c79cfb95 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -0,0 +1,69 @@ +/* + * 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 request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('legacyEs'); + const config = getService('config'); + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + + async function checkSessionCookie(sessionCookie: Cookie, providerName: string) { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + async function getNumberOfSessionDocuments() { + return (await es.search({ index: '.kibana_security_session' })).hits.total.value; + } + + describe('Session Idle cleanup', () => { + beforeEach(async () => { + await es.deleteByQuery({ index: '.kibana_security_session', q: '*', refresh: true }); + }); + + it('should properly clean up session expired because of idle timeout', async function () { + this.timeout(180000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Cleanup routine runs every 60s, let's wait for 120s to make sure it runs when idle timeout + // threshold is exceeded. + await delay(120000); + + // Session info is removed from the index and cookie isn't valid anymore + expect(await getNumberOfSessionDocuments()).to.be(0); + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts similarity index 100% rename from x-pack/test/api_integration/apis/security/session.ts rename to x-pack/test/security_api_integration/tests/session_idle/extension.ts diff --git a/x-pack/test/security_api_integration/tests/session_idle/index.ts b/x-pack/test/security_api_integration/tests/session_idle/index.ts new file mode 100644 index 0000000000000..85dfba2b6b5ef --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_idle/index.ts @@ -0,0 +1,16 @@ +/* + * 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 APIs - Session Idle', function () { + this.tags('ciGroup6'); + + loadTestFile(require.resolve('./cleanup')); + loadTestFile(require.resolve('./extension')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts new file mode 100644 index 0000000000000..c26bd4a3c29ba --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -0,0 +1,69 @@ +/* + * 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 request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('legacyEs'); + const config = getService('config'); + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + + async function checkSessionCookie(sessionCookie: Cookie, providerName: string) { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + async function getNumberOfSessionDocuments() { + return (await es.search({ index: '.kibana_security_session' })).hits.total.value; + } + + describe('Session Lifespan cleanup', () => { + beforeEach(async () => { + await es.deleteByQuery({ index: '.kibana_security_session', q: '*', refresh: true }); + }); + + it('should properly clean up session expired because of lifespan', async function () { + this.timeout(180000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Cleanup routine runs every 60s, let's wait for 120s to make sure it runs when lifespan is + // exceeded. + await delay(120000); + + // Session info is removed from the index and cookie isn't valid anymore + expect(await getNumberOfSessionDocuments()).to.be(0); + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/index.ts b/x-pack/test/security_api_integration/tests/session_lifespan/index.ts new file mode 100644 index 0000000000000..bfa14dd1076f2 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_lifespan/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 APIs - Session Lifespan', function () { + this.tags('ciGroup6'); + + loadTestFile(require.resolve('./cleanup')); + }); +} From 11b3c4e6f23b285ac2e56467a4a106ea20b285af Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 16 Jul 2020 19:46:48 +0200 Subject: [PATCH 09/28] Fix outdated test file link. --- x-pack/test/api_integration/apis/security/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/security/index.js b/x-pack/test/api_integration/apis/security/index.js index 91552a3b873a8..19eddb311b451 100644 --- a/x-pack/test/api_integration/apis/security/index.js +++ b/x-pack/test/api_integration/apis/security/index.js @@ -18,6 +18,5 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./index_fields')); loadTestFile(require.resolve('./roles')); loadTestFile(require.resolve('./privileges')); - loadTestFile(require.resolve('./session')); }); } From 8bd08e55ed7021be250c07c3c029f8d70370e6bf Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 16 Jul 2020 22:02:49 +0200 Subject: [PATCH 10/28] Fix more outdated test file links. --- x-pack/test/api_integration/apis/security/security_basic.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/security/security_basic.ts b/x-pack/test/api_integration/apis/security/security_basic.ts index 3bae51d3b86d8..191523e969717 100644 --- a/x-pack/test/api_integration/apis/security/security_basic.ts +++ b/x-pack/test/api_integration/apis/security/security_basic.ts @@ -20,6 +20,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./index_fields')); loadTestFile(require.resolve('./roles')); loadTestFile(require.resolve('./privileges_basic')); - loadTestFile(require.resolve('./session')); }); } From ccbcec1c1366feb29f5bdcc7e402f7d3f218bc15 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 17 Jul 2020 09:11:46 +0200 Subject: [PATCH 11/28] More tweaks to the brand new session api integration tests. --- .../test/security_api_integration/session_idle.config.ts | 2 +- .../security_api_integration/session_lifespan.config.ts | 3 +-- .../tests/session_idle/cleanup.ts | 8 +++++++- .../tests/session_lifespan/cleanup.ts | 8 +++++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/x-pack/test/security_api_integration/session_idle.config.ts b/x-pack/test/security_api_integration/session_idle.config.ts index 1f1a38e01e164..467a2880f125f 100644 --- a/x-pack/test/security_api_integration/session_idle.config.ts +++ b/x-pack/test/security_api_integration/session_idle.config.ts @@ -36,7 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, junit: { - reportName: 'Chrome X-Pack Security API Integration Tests', + reportName: 'X-Pack Security API Integration Tests', }, }; } diff --git a/x-pack/test/security_api_integration/session_lifespan.config.ts b/x-pack/test/security_api_integration/session_lifespan.config.ts index 10c0ebbb34601..d7f5a6c17380c 100644 --- a/x-pack/test/security_api_integration/session_lifespan.config.ts +++ b/x-pack/test/security_api_integration/session_lifespan.config.ts @@ -32,12 +32,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), '--xpack.security.session.lifespan=20s', '--xpack.security.session.cleanupInterval=60s', - `--config=/projects/elastic/master/kibana/config/kibana.dev.test.yml`, ], }, junit: { - reportName: 'Chrome X-Pack Security API Integration Tests', + reportName: 'X-Pack Security API Integration Tests', }, }; } diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index c7728c79cfb95..f770abd9dc089 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -32,7 +32,13 @@ export default function ({ getService }: FtrProviderContext) { describe('Session Idle cleanup', () => { beforeEach(async () => { - await es.deleteByQuery({ index: '.kibana_security_session', q: '*', refresh: true }); + await es.deleteByQuery({ + index: '.kibana_security_session', + q: '*', + wait_for_completion: true, + refresh: true, + ignore: [404], + }); }); it('should properly clean up session expired because of idle timeout', async function () { diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index c26bd4a3c29ba..f1f308a36ba51 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -32,7 +32,13 @@ export default function ({ getService }: FtrProviderContext) { describe('Session Lifespan cleanup', () => { beforeEach(async () => { - await es.deleteByQuery({ index: '.kibana_security_session', q: '*', refresh: true }); + await es.deleteByQuery({ + index: '.kibana_security_session', + q: '*', + wait_for_completion: true, + refresh: true, + ignore: [404], + }); }); it('should properly clean up session expired because of lifespan', async function () { From 04801a1e476d2269f71afbe05812a7a041528db2 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 17 Jul 2020 12:38:01 +0200 Subject: [PATCH 12/28] Wait for the `GREEN` status of session index before running tests. --- .../security_api_integration/tests/session_idle/cleanup.ts | 6 +++++- .../tests/session_lifespan/cleanup.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index f770abd9dc089..f024c52c7e71e 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -31,11 +31,15 @@ export default function ({ getService }: FtrProviderContext) { } describe('Session Idle cleanup', () => { + before(async () => { + await es.cluster.health({ index: '.kibana_security_session', waitForStatus: 'green' }); + }); + beforeEach(async () => { await es.deleteByQuery({ index: '.kibana_security_session', q: '*', - wait_for_completion: true, + waitForCompletion: true, refresh: true, ignore: [404], }); diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index f1f308a36ba51..0d2ef9db6d03f 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -31,11 +31,15 @@ export default function ({ getService }: FtrProviderContext) { } describe('Session Lifespan cleanup', () => { + before(async () => { + await es.cluster.health({ index: '.kibana_security_session', waitForStatus: 'green' }); + }); + beforeEach(async () => { await es.deleteByQuery({ index: '.kibana_security_session', q: '*', - wait_for_completion: true, + waitForCompletion: true, refresh: true, ignore: [404], }); From b91f0508c674980f97c6d4712966e286ecf2d5c2 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 31 Jul 2020 10:15:28 +0200 Subject: [PATCH 13/28] Review#2: handle review feedback. --- .../server/authentication/authenticator.ts | 2 +- .../server/session_management/session.test.ts | 1 - .../server/session_management/session.ts | 6 +- .../session_management/session_index.mock.ts | 1 - .../session_management/session_index.test.ts | 50 +++++--------- .../session_management/session_index.ts | 69 +++++++------------ .../session_management_service.test.ts | 2 +- .../session_management_service.ts | 8 +-- 8 files changed, 52 insertions(+), 87 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index d362d7ee4feb6..3e4e155ffbf4c 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -561,7 +561,7 @@ export class Authenticator { const providerHasChanged = !!existingSessionValue && !ownsSession; const sessionHasBeenAuthenticated = - !isExistingSessionAuthenticated && isNewSessionAuthenticated; + !!existingSessionValue && !isExistingSessionAuthenticated && isNewSessionAuthenticated; const usernameHasChanged = isExistingSessionAuthenticated && isNewSessionAuthenticated && diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index 13dbdb7b15b0c..cbae5359969b3 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -311,7 +311,6 @@ describe('Session', () => { 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJCt8yPPMsaNAxn7qtLtc57UN967e9FpjmJgEIipe6nD20F47TtNIZnAuzd75zc8TNWvPMgRTzpHnYz7cT9m5ouv2V8TZ+ow==', provider: { name: 'basic1', type: 'basic' }, usernameHash: '35133597af273830c3f139c72501e676338f28a39dca8ff62d5c2b8bfba75f69', - tenant: 'some-tenant', idleTimeoutExpiration: now + 123, lifespanExpiration: now + 1, metadata: { primaryTerm: 1, sequenceNumber: 1 }, diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index a23361dadbdff..30e455bfe9598 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -150,7 +150,9 @@ export class Session { (await this.crypto.decrypt(sessionIndexValue.content, sessionCookieValue.aad)) as string ); } catch (err) { - this.options.logger.warn('Unable to decrypt session content, session will be invalidated.'); + this.options.logger.warn( + `Unable to decrypt session content, session will be invalidated: ${err.message}` + ); await this.clear(request); return null; } @@ -379,7 +381,7 @@ export class Session { { username, state }: SessionValueContentToEncrypt ): Readonly { // Extract values that are specific to session index value. - const { usernameHash, content, tenant, ...publicSessionValue } = sessionIndexValue; + const { usernameHash, content, ...publicSessionValue } = sessionIndexValue; return { ...publicSessionValue, username, state, metadata: { index: sessionIndexValue } }; } } diff --git a/x-pack/plugins/security/server/session_management/session_index.mock.ts b/x-pack/plugins/security/server/session_management/session_index.mock.ts index e26df4ecb4ea7..81dbe4b7410b8 100644 --- a/x-pack/plugins/security/server/session_management/session_index.mock.ts +++ b/x-pack/plugins/security/server/session_management/session_index.mock.ts @@ -22,7 +22,6 @@ export const sessionIndexMock = { provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: null, lifespanExpiration: null, - tenant: 'some-tenant', content: 'some-encrypted-content', metadata: { primaryTerm: 1, sequenceNumber: 1 }, ...sessionValue, diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index e5189a6e961bc..0ef48347d7bad 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -7,9 +7,9 @@ import { ILegacyClusterClient } from '../../../../../src/core/server'; import { ConfigSchema, createConfig } from '../config'; import { + getSessionIndexTemplate, SESSION_INDEX_ALIAS, - SESSION_INDEX_NAME, - SESSION_INDEX_TEMPLATE, + SESSION_INDEX_TEMPLATE_NAME, SessionIndex, } from './session_index'; @@ -19,11 +19,12 @@ import { sessionIndexMock } from './session_index.mock'; describe('Session index', () => { let mockClusterClient: jest.Mocked; let sessionIndex: SessionIndex; + const indexName = '.kibana_some_tenant_security_session_1'; beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); const sessionIndexOptions = { logger: loggingSystemMock.createLogger(), - tenant: 'some-tenant', + indexName, config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), @@ -36,10 +37,10 @@ describe('Session index', () => { describe('#initialize', () => { function assertExistenceChecksPerformed() { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { - name: SESSION_INDEX_TEMPLATE.name, + name: SESSION_INDEX_TEMPLATE_NAME, }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { - index: SESSION_INDEX_TEMPLATE.template.index_patterns, + index: getSessionIndexTemplate(indexName).index_patterns, }); } @@ -87,14 +88,15 @@ describe('Session index', () => { await sessionIndex.initialize(); + const expectedIndexTemplate = getSessionIndexTemplate(indexName); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); assertExistenceChecksPerformed(); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE.name, - body: SESSION_INDEX_TEMPLATE.template, + name: SESSION_INDEX_TEMPLATE_NAME, + body: expectedIndexTemplate, }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { - index: SESSION_INDEX_TEMPLATE.template.index_patterns, + index: expectedIndexTemplate.index_patterns, }); }); @@ -114,8 +116,8 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE.name, - body: SESSION_INDEX_TEMPLATE.template, + name: SESSION_INDEX_TEMPLATE_NAME, + body: getSessionIndexTemplate(indexName), }); }); @@ -135,7 +137,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { - index: SESSION_INDEX_TEMPLATE.template.index_patterns, + index: getSessionIndexTemplate(indexName).index_patterns, }); }); @@ -184,12 +186,10 @@ describe('Session index', () => { body: { query: { bool: { - filter: { term: { tenant: 'some-tenant' } }, should: [ { range: { lifespanExpiration: { lte: now } } }, { range: { idleTimeoutExpiration: { lte: now } } }, ], - minimum_should_match: 1, }, }, }, @@ -199,7 +199,7 @@ describe('Session index', () => { it('when only `lifespan` is configured', async () => { sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - tenant: 'some-tenant', + indexName, config: createConfig( ConfigSchema.validate({ session: { lifespan: 456 } }), loggingSystemMock.createLogger(), @@ -218,13 +218,11 @@ describe('Session index', () => { body: { query: { bool: { - filter: { term: { tenant: 'some-tenant' } }, should: [ { range: { lifespanExpiration: { lte: now } } }, { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, { range: { idleTimeoutExpiration: { lte: now } } }, ], - minimum_should_match: 1, }, }, }, @@ -235,7 +233,7 @@ describe('Session index', () => { const idleTimeout = 123; sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - tenant: 'some-tenant', + indexName, config: createConfig( ConfigSchema.validate({ session: { idleTimeout } }), loggingSystemMock.createLogger(), @@ -254,13 +252,11 @@ describe('Session index', () => { body: { query: { bool: { - filter: { term: { tenant: 'some-tenant' } }, should: [ { range: { lifespanExpiration: { lte: now } } }, { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, ], - minimum_should_match: 1, }, }, }, @@ -271,7 +267,7 @@ describe('Session index', () => { const idleTimeout = 123; sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - tenant: 'some-tenant', + indexName, config: createConfig( ConfigSchema.validate({ session: { idleTimeout, lifespan: 456 } }), loggingSystemMock.createLogger(), @@ -290,14 +286,12 @@ describe('Session index', () => { body: { query: { bool: { - filter: { term: { tenant: 'some-tenant' } }, should: [ { range: { lifespanExpiration: { lte: now } } }, { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, ], - minimum_should_match: 1, }, }, }, @@ -334,7 +328,6 @@ describe('Session index', () => { provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: 123, lifespanExpiration: null, - tenant: 'some-tenant', content: 'some-encrypted-content', }; @@ -396,15 +389,14 @@ describe('Session index', () => { await expect(sessionIndex.create({ sid, ...sessionValue })).resolves.toEqual({ ...sessionValue, sid, - tenant: 'some-tenant', metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('create', { id: sid, - index: SESSION_INDEX_NAME, - body: { ...sessionValue, tenant: 'some-tenant' }, + index: indexName, + body: sessionValue, refresh: 'wait_for', }); }); @@ -424,7 +416,6 @@ describe('Session index', () => { provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: 100, lifespanExpiration: 200, - tenant: 'some-tenant', content: 'some-updated-encrypted-content', }; @@ -451,14 +442,12 @@ describe('Session index', () => { provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: null, lifespanExpiration: null, - tenant: 'some-other-tenant', content: 'some-encrypted-content', }; await expect(sessionIndex.update({ sid, metadata, ...sessionValue })).resolves.toEqual({ ...latestSessionValue, sid, - tenant: 'some-tenant', metadata: { primaryTerm: 321, sequenceNumber: 654 }, }); @@ -466,7 +455,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { id: sid, index: SESSION_INDEX_ALIAS, - body: { ...sessionValue, tenant: 'some-tenant' }, + body: sessionValue, ifSeqNo: 456, ifPrimaryTerm: 123, refresh: 'wait_for', @@ -488,7 +477,6 @@ describe('Session index', () => { provider: { type: 'basic', name: 'basic1' }, idleTimeoutExpiration: null, lifespanExpiration: null, - tenant: 'some-tenant', content: 'some-encrypted-content', }; diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index ba0f8f38bc17f..dc8f017ffa02e 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -10,34 +10,32 @@ import { ConfigType } from '../config'; export interface SessionIndexOptions { readonly clusterClient: ILegacyClusterClient; - readonly tenant: string; + readonly indexName: string; readonly config: Pick; readonly logger: Logger; } /** - * Version of the current session index template. + * Alias of the Elasticsearch index that is used to store user session information. */ -const SESSION_INDEX_TEMPLATE_VERSION = 1; +export const SESSION_INDEX_ALIAS = '.kibana_security_session'; /** - * Alias of the Elasticsearch index that is used to store user session information. + * Version of the current session index template. */ -export const SESSION_INDEX_ALIAS = '.kibana_security_session'; +export const SESSION_INDEX_TEMPLATE_VERSION = 1; /** - * Name of the Elasticsearch index that is used to store user session information. + * Name of the current session index template. */ -export const SESSION_INDEX_NAME = `${SESSION_INDEX_ALIAS}_${SESSION_INDEX_TEMPLATE_VERSION}`; +export const SESSION_INDEX_TEMPLATE_NAME = `${SESSION_INDEX_ALIAS}_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`; /** - * Index template that is used for the current version of the session index. + * Returns index template that is used for the current version of the session index. */ -export 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, +export function getSessionIndexTemplate(indexName: string) { + return Object.freeze({ + index_patterns: indexName, order: 1000, settings: { index: { @@ -54,7 +52,6 @@ export const SESSION_INDEX_TEMPLATE = { properties: { usernameHash: { type: 'keyword' }, provider: { properties: { name: { type: 'keyword' }, type: { type: 'keyword' } } }, - tenant: { type: 'keyword' }, idleTimeoutExpiration: { type: 'date' }, lifespanExpiration: { type: 'date' }, accessAgreementAcknowledged: { type: 'boolean' }, @@ -62,8 +59,8 @@ export const SESSION_INDEX_TEMPLATE = { }, }, aliases: { [SESSION_INDEX_ALIAS]: {} }, - }, -}; + }); +} /** * Represents shape of the session value stored in the index. @@ -74,11 +71,6 @@ export interface SessionIndexValue { */ sid: string; - /** - * Name of the current Kibana tenant. - */ - tenant: 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). @@ -191,7 +183,7 @@ export class SessionIndex { * Creates a new document for the specified session value. * @param sessionValue Session index value. */ - async create(sessionValue: Readonly>) { + async create(sessionValue: Readonly>) { if (this.indexInitialization) { this.options.logger.warn( 'Attempted to create a new session while session index is initializing.' @@ -210,16 +202,12 @@ export class SessionIndex { // 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, tenant: this.options.tenant }, + index: this.options.indexName, + body: sessionValueToStore, refresh: 'wait_for', }); - return { - ...sessionValue, - tenant: this.options.tenant, - metadata: { primaryTerm, sequenceNumber }, - } as SessionIndexValue; + return { ...sessionValue, metadata: { primaryTerm, sequenceNumber } } as SessionIndexValue; } catch (err) { this.options.logger.error(`Failed to create session value: ${err.message}`); throw err; @@ -236,7 +224,7 @@ export class SessionIndex { const response = await this.options.clusterClient.callAsInternalUser('index', { id: sid, index: SESSION_INDEX_ALIAS, - body: { ...sessionValueToStore, tenant: this.options.tenant }, + body: sessionValueToStore, ifSeqNo: metadata.sequenceNumber, ifPrimaryTerm: metadata.primaryTerm, refresh: 'wait_for', @@ -256,7 +244,6 @@ export class SessionIndex { return { ...sessionValue, - tenant: this.options.tenant, metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, } as SessionIndexValue; } catch (err) { @@ -299,7 +286,7 @@ export class SessionIndex { try { indexTemplateExists = await this.options.clusterClient.callAsInternalUser( 'indices.existsTemplate', - { name: SESSION_INDEX_TEMPLATE.name } + { name: SESSION_INDEX_TEMPLATE_NAME } ); } catch (err) { this.options.logger.error( @@ -314,8 +301,8 @@ export class SessionIndex { } else { try { await this.options.clusterClient.callAsInternalUser('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE.name, - body: SESSION_INDEX_TEMPLATE.template, + name: SESSION_INDEX_TEMPLATE_NAME, + body: getSessionIndexTemplate(this.options.indexName), }); this.options.logger.debug('Successfully created session index template.'); } catch (err) { @@ -329,7 +316,7 @@ export class SessionIndex { let indexExists = false; try { indexExists = await this.options.clusterClient.callAsInternalUser('indices.exists', { - index: SESSION_INDEX_NAME, + index: this.options.indexName, }); } catch (err) { this.options.logger.error(`Failed to check if session index exists: ${err.message}`); @@ -342,7 +329,7 @@ export class SessionIndex { } else { try { await this.options.clusterClient.callAsInternalUser('indices.create', { - index: SESSION_INDEX_NAME, + index: this.options.indexName, }); this.options.logger.debug('Successfully created session index.'); } catch (err) { @@ -394,15 +381,7 @@ export class SessionIndex { index: SESSION_INDEX_ALIAS, refresh: 'wait_for', ignore: [409, 404], - body: { - query: { - bool: { - filter: { term: { tenant: this.options.tenant } }, - should: deleteQueries, - minimum_should_match: 1, - }, - }, - }, + body: { query: { bool: { should: deleteQueries } } }, }); if (response.deleted > 0) { diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index aed276725d77e..ef6afc18a083d 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -49,7 +49,7 @@ describe('SessionManagementService', () => { expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { - title: 'Cleanup expired or invalid sessions', + title: 'Cleanup expired or invalid user sessions', type: SESSION_INDEX_CLEANUP_TASK_NAME, createTaskRunner: expect.any(Function), }, 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 index 07df578d2bc7e..c1cc69979149b 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -5,13 +5,12 @@ */ import { Observable, Subscription } from 'rxjs'; -import { createHash } from 'crypto'; import { HttpServiceSetup, ILegacyClusterClient, Logger } from '../../../../../src/core/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../../task_manager/server'; import { ConfigType } from '../config'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; import { SessionCookie } from './session_cookie'; -import { SessionIndex } from './session_index'; +import { SessionIndex, SESSION_INDEX_TEMPLATE_VERSION } from './session_index'; import { Session } from './session'; export interface SessionManagementServiceSetupParams { @@ -65,15 +64,14 @@ export class SessionManagementService { this.sessionIndex = new SessionIndex({ config, clusterClient, - // Unique identifier of Kibana "tenant" based on the .kibana index name it relies on. - tenant: createHash('sha3-256').update(kibanaIndexName).digest('hex'), + indexName: `${kibanaIndexName}_security_session_${SESSION_INDEX_TEMPLATE_VERSION}`, logger: this.logger.get('index'), }); // Register task that will perform periodic session index cleanup. taskManager.registerTaskDefinitions({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { - title: 'Cleanup expired or invalid sessions', + title: 'Cleanup expired or invalid user sessions', type: SESSION_INDEX_CLEANUP_TASK_NAME, createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), }, From e98dbd0b53afa47213ae057faaf81b66dc341724 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 3 Aug 2020 14:31:07 +0200 Subject: [PATCH 14/28] Review#2: properly update session index when user is active. --- .../server/session_management/session.test.ts | 29 +++++++++++++-- .../server/session_management/session.ts | 3 +- .../session_management/session_index.ts | 4 +-- .../tests/session_idle/cleanup.ts | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index cbae5359969b3..873714b32d4d3 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -483,7 +483,16 @@ describe('Session', () => { await expect( session.extend( mockRequest, - sessionMock.createValue({ idleTimeoutExpiration: now + 1, lifespanExpiration: now + 2 }) + sessionMock.createValue({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 2, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: now - 123, + lifespanExpiration: now + 2, + }), + }, + }) ) ).resolves.toEqual( expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: now + 2 }) @@ -519,7 +528,14 @@ describe('Session', () => { await expect( session.extend( mockRequest, - sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 }) + sessionMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 123, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 2 * 123, + }), + }, + }) ) ).resolves.toEqual( expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) @@ -569,7 +585,14 @@ describe('Session', () => { await expect( session.extend( mockRequest, - sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1 }) + sessionMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 123, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1, + }), + }, + }) ) ).resolves.toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 30e455bfe9598..df3592affb098 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -298,7 +298,8 @@ export class Session { } else if ( this.idleIndexUpdateTimeout !== null && this.idleIndexUpdateTimeout < - sessionExpirationInfo.idleTimeoutExpiration! - sessionValue.idleTimeoutExpiration! + sessionExpirationInfo.idleTimeoutExpiration! - + sessionValue.metadata.index.idleTimeoutExpiration! ) { // 3. If idle timeout was updated a while ago. this.options.logger.debug( diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index dc8f017ffa02e..c2694c38f1f5f 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -358,7 +358,7 @@ export class SessionIndex { // Always try to delete sessions with expired lifespan (even if it's not configured right now). const deleteQueries: object[] = [{ range: { lifespanExpiration: { lte: now } } }]; - // If lifespan is configured we should remove sessions that were created without it if any. + // If lifespan is configured we should remove any sessions that were created without one. if (this.options.config.session.lifespan) { deleteQueries.push({ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }); } @@ -372,7 +372,7 @@ export class SessionIndex { ); } else { // Otherwise just delete all expired sessions that were previously created with the idle - // timeout if any. + // timeout. deleteQueries.push({ range: { idleTimeoutExpiration: { lte: now } } }); } diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index f024c52c7e71e..4b635fc92e328 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -7,6 +7,7 @@ import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; +import { ToolingLog } from '@kbn/dev-utils'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { @@ -24,6 +25,8 @@ export default function ({ getService }: FtrProviderContext) { expect(apiResponse.body.username).to.be(username); expect(apiResponse.body.authentication_provider).to.be(providerName); + + return request.cookie(apiResponse.headers['set-cookie'][0])!; } async function getNumberOfSessionDocuments() { @@ -75,5 +78,38 @@ export default function ({ getService }: FtrProviderContext) { .set('Cookie', sessionCookie.cookieString()) .expect(401); }); + + it('should not clean up session if user is active', async function () { + this.timeout(180000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + let sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Run 15 consequent requests with 10s delay, during this time cleanup procedure should run at + // least twice. + const log = new ToolingLog({ level: 'info', writeTo: process.stdout }); + for (const counter of [...Array(15).keys()]) { + // Session idle timeout is 15s, let's wait 10s and make a new request that would extend the session. + await delay(10000); + + sessionCookie = await checkSessionCookie(sessionCookie, 'basic'); + log.info(`Session is still valid after ${(counter + 1) * 10}s`); + } + + // Session document should still be present. + expect(await getNumberOfSessionDocuments()).to.be(1); + }); }); } From a86884cc3805fee1f9ac4bf84c9889fa7bffc161 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 3 Aug 2020 15:05:23 +0200 Subject: [PATCH 15/28] Remove duplicated test suite declaration. --- x-pack/scripts/functional_tests.js | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 2071b77c00d41..205ff500a36ec 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,7 +9,6 @@ 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_basic.ts'), require.resolve('../test/security_functional/login_selector.config.ts'), require.resolve('../test/security_functional/oidc.config.ts'), require.resolve('../test/security_functional/saml.config.ts'), From 6acf00579aa23a15c1d21346f6b9eecdb45f9bcd Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 3 Aug 2020 16:16:18 +0200 Subject: [PATCH 16/28] Review#2: use SID-scoped loggers inside of `Session`. --- .../server/session_management/session.ts | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index df3592affb098..0276512c23e0d 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -124,20 +124,21 @@ export class Session { return null; } + const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid); const now = Date.now(); if ( (sessionCookieValue.idleTimeoutExpiration && sessionCookieValue.idleTimeoutExpiration < now) || (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < now) ) { - this.options.logger.debug('Session has expired and will be invalidated.'); + sessionLogger.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( + sessionLogger.debug( 'Session value is not available in the index, session cookie will be invalidated.' ); await this.options.sessionCookie.clear(request); @@ -150,7 +151,7 @@ export class Session { (await this.crypto.decrypt(sessionIndexValue.content, sessionCookieValue.aad)) as string ); } catch (err) { - this.options.logger.warn( + sessionLogger.warn( `Unable to decrypt session content, session will be invalidated: ${err.message}` ); await this.clear(request); @@ -180,6 +181,9 @@ export class Session { this.randomBytes(256).then((aadBuffer) => aadBuffer.toString('base64')), ]); + const sessionLogger = this.getLoggerForSID(sid); + sessionLogger.debug('Creating a new session.'); + const sessionExpirationInfo = this.calculateExpiry(); const { username, state, ...publicSessionValue } = sessionValue; @@ -195,7 +199,7 @@ export class Session { await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad }); - this.options.logger.debug('Successfully created new session.'); + sessionLogger.debug('Successfully created a new session.'); return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); } @@ -207,8 +211,9 @@ export class Session { */ async update(request: KibanaRequest, sessionValue: Readonly) { const sessionCookieValue = await this.options.sessionCookie.get(request); + const sessionLogger = this.getLoggerForSID(sessionValue.sid); if (!sessionCookieValue) { - this.options.logger.warn('Session cannot be updated since it does not exist.'); + sessionLogger.warn('Session cannot be updated since it does not exist.'); return null; } @@ -231,7 +236,7 @@ export class Session { // 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.'); + sessionLogger.warn('Session cannot be updated as it has been invalidated already.'); await this.options.sessionCookie.clear(request); return null; } @@ -241,7 +246,7 @@ export class Session { ...sessionExpirationInfo, }); - this.options.logger.debug('Successfully updated existing session.'); + sessionLogger.debug('Successfully updated existing session.'); return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); } @@ -253,8 +258,9 @@ export class Session { */ async extend(request: KibanaRequest, sessionValue: Readonly) { const sessionCookieValue = await this.options.sessionCookie.get(request); + const sessionLogger = this.getLoggerForSID(sessionValue.sid); if (!sessionCookieValue) { - this.options.logger.warn('Session cannot be extended since it does not exist.'); + sessionLogger.warn('Session cannot be extended since it does not exist.'); return null; } @@ -279,7 +285,7 @@ export class Session { ) { // 1. If idle timeout wasn't configured when session was initially created and is configured // now or vice versa. - this.options.logger.debug( + sessionLogger.debug( 'Session idle timeout configuration has changed, session index will be updated.' ); updateSessionIndex = true; @@ -291,7 +297,7 @@ export class Session { ) { // 2. If lifespan wasn't configured when session was initially created and is configured now // or vice versa. - this.options.logger.debug( + sessionLogger.debug( 'Session lifespan configuration has changed, session index will be updated.' ); updateSessionIndex = true; @@ -302,7 +308,7 @@ export class Session { sessionValue.metadata.index.idleTimeoutExpiration! ) { // 3. If idle timeout was updated a while ago. - this.options.logger.debug( + sessionLogger.debug( 'Session idle timeout stored in the index is too old and will be updated.' ); updateSessionIndex = true; @@ -319,7 +325,7 @@ export class Session { // 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.'); + sessionLogger.warn('Session cannot be extended as it has been invalidated already.'); await this.options.sessionCookie.clear(request); return null; } @@ -332,7 +338,7 @@ export class Session { ...sessionExpirationInfo, }); - this.options.logger.debug('Successfully extended existing session.'); + sessionLogger.debug('Successfully extended existing session.'); return { ...sessionValue, ...sessionExpirationInfo } as Readonly; } @@ -347,12 +353,15 @@ export class Session { return; } + const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid); + sessionLogger.debug('Invalidating session.'); + await Promise.all([ this.options.sessionCookie.clear(request), this.options.sessionIndex.clear(sessionCookieValue.sid), ]); - this.options.logger.debug('Successfully invalidated existing session.'); + sessionLogger.debug('Successfully invalidated session.'); } private calculateExpiry( @@ -385,4 +394,12 @@ export class Session { const { usernameHash, content, ...publicSessionValue } = sessionIndexValue; return { ...publicSessionValue, username, state, metadata: { index: sessionIndexValue } }; } + + /** + * Creates logger scoped to a specified session ID. + * @param sid Session ID to create logger for. + */ + private getLoggerForSID(sid: string) { + return this.options.logger.get(sid?.slice(-10)); + } } From a8d46f171c0ecf328600ce94842fb7a37d8fdb3f Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 4 Aug 2020 09:05:20 +0200 Subject: [PATCH 17/28] Review#3: generate index name inside of `SessionIndex` and get rid of session index alias. --- .../session_management/session_index.test.ts | 38 ++++++++--------- .../session_management/session_index.ts | 41 ++++++++----------- .../session_management_service.ts | 4 +- .../tests/session_idle/cleanup.ts | 6 +-- .../tests/session_lifespan/cleanup.ts | 6 +-- 5 files changed, 43 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index 0ef48347d7bad..f4ff5a8bddb74 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -6,12 +6,7 @@ import { ILegacyClusterClient } from '../../../../../src/core/server'; import { ConfigSchema, createConfig } from '../config'; -import { - getSessionIndexTemplate, - SESSION_INDEX_ALIAS, - SESSION_INDEX_TEMPLATE_NAME, - SessionIndex, -} from './session_index'; +import { getSessionIndexTemplate, SessionIndex } from './session_index'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { sessionIndexMock } from './session_index.mock'; @@ -20,11 +15,12 @@ describe('Session index', () => { let mockClusterClient: jest.Mocked; let sessionIndex: SessionIndex; const indexName = '.kibana_some_tenant_security_session_1'; + const indexTemplateName = '.kibana_some_tenant_security_session_index_template_1'; beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); const sessionIndexOptions = { logger: loggingSystemMock.createLogger(), - indexName, + kibanaIndexName: '.kibana_some_tenant', config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { isTLSEnabled: false, }), @@ -37,7 +33,7 @@ describe('Session index', () => { describe('#initialize', () => { function assertExistenceChecksPerformed() { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { - name: SESSION_INDEX_TEMPLATE_NAME, + name: indexTemplateName, }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { index: getSessionIndexTemplate(indexName).index_patterns, @@ -92,7 +88,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); assertExistenceChecksPerformed(); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE_NAME, + name: indexTemplateName, body: expectedIndexTemplate, }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { @@ -116,7 +112,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); assertExistenceChecksPerformed(); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE_NAME, + name: indexTemplateName, body: getSessionIndexTemplate(indexName), }); }); @@ -180,7 +176,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: SESSION_INDEX_ALIAS, + index: indexName, refresh: 'wait_for', ignore: [409, 404], body: { @@ -199,7 +195,7 @@ describe('Session index', () => { it('when only `lifespan` is configured', async () => { sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - indexName, + kibanaIndexName: '.kibana_some_tenant', config: createConfig( ConfigSchema.validate({ session: { lifespan: 456 } }), loggingSystemMock.createLogger(), @@ -212,7 +208,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: SESSION_INDEX_ALIAS, + index: indexName, refresh: 'wait_for', ignore: [409, 404], body: { @@ -233,7 +229,7 @@ describe('Session index', () => { const idleTimeout = 123; sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - indexName, + kibanaIndexName: '.kibana_some_tenant', config: createConfig( ConfigSchema.validate({ session: { idleTimeout } }), loggingSystemMock.createLogger(), @@ -246,7 +242,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: SESSION_INDEX_ALIAS, + index: indexName, refresh: 'wait_for', ignore: [409, 404], body: { @@ -267,7 +263,7 @@ describe('Session index', () => { const idleTimeout = 123; sessionIndex = new SessionIndex({ logger: loggingSystemMock.createLogger(), - indexName, + kibanaIndexName: '.kibana_some_tenant', config: createConfig( ConfigSchema.validate({ session: { idleTimeout, lifespan: 456 } }), loggingSystemMock.createLogger(), @@ -280,7 +276,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { - index: SESSION_INDEX_ALIAS, + index: indexName, refresh: 'wait_for', ignore: [409, 404], body: { @@ -349,7 +345,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('get', { id: 'some-sid', ignore: [404], - index: SESSION_INDEX_ALIAS, + index: indexName, }); }); }); @@ -454,7 +450,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { id: sid, - index: SESSION_INDEX_ALIAS, + index: indexName, body: sessionValue, ifSeqNo: 456, ifPrimaryTerm: 123, @@ -489,7 +485,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { id: sid, - index: SESSION_INDEX_ALIAS, + index: indexName, body: sessionValue, ifSeqNo: 456, ifPrimaryTerm: 123, @@ -513,7 +509,7 @@ describe('Session index', () => { expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('delete', { id: 'some-long-sid', - index: SESSION_INDEX_ALIAS, + index: indexName, refresh: 'wait_for', ignore: [404], }); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index c2694c38f1f5f..e7a56c65358af 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -10,25 +10,15 @@ import { ConfigType } from '../config'; export interface SessionIndexOptions { readonly clusterClient: ILegacyClusterClient; - readonly indexName: string; + readonly kibanaIndexName: string; readonly config: Pick; readonly logger: Logger; } -/** - * Alias of the Elasticsearch index that is used to store user session information. - */ -export const SESSION_INDEX_ALIAS = '.kibana_security_session'; - /** * Version of the current session index template. */ -export const SESSION_INDEX_TEMPLATE_VERSION = 1; - -/** - * Name of the current session index template. - */ -export const SESSION_INDEX_TEMPLATE_NAME = `${SESSION_INDEX_ALIAS}_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`; +const SESSION_INDEX_TEMPLATE_VERSION = 1; /** * Returns index template that is used for the current version of the session index. @@ -58,7 +48,6 @@ export function getSessionIndexTemplate(indexName: string) { content: { type: 'binary' }, }, }, - aliases: { [SESSION_INDEX_ALIAS]: {} }, }); } @@ -126,6 +115,11 @@ interface SessionIndexValueMetadata { } export class SessionIndex { + /** + * Name of the index to store session information in. + */ + private readonly indexName = `${this.options.kibanaIndexName}_security_session_${SESSION_INDEX_TEMPLATE_VERSION}`; + /** * Timeout after which session with the expired idle timeout _may_ be removed from the index * during regular cleanup routine. @@ -158,7 +152,7 @@ export class SessionIndex { const response = await this.options.clusterClient.callAsInternalUser('get', { id: sid, ignore: [404], - index: SESSION_INDEX_ALIAS, + index: this.indexName, }); const docNotFound = response.found === false; @@ -202,7 +196,7 @@ export class SessionIndex { // 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: this.options.indexName, + index: this.indexName, body: sessionValueToStore, refresh: 'wait_for', }); @@ -223,7 +217,7 @@ export class SessionIndex { try { const response = await this.options.clusterClient.callAsInternalUser('index', { id: sid, - index: SESSION_INDEX_ALIAS, + index: this.indexName, body: sessionValueToStore, ifSeqNo: metadata.sequenceNumber, ifPrimaryTerm: metadata.primaryTerm, @@ -262,7 +256,7 @@ export class SessionIndex { // over any updates that could happen in the meantime. await this.options.clusterClient.callAsInternalUser('delete', { id: sid, - index: SESSION_INDEX_ALIAS, + index: this.indexName, refresh: 'wait_for', ignore: [404], }); @@ -280,13 +274,14 @@ export class SessionIndex { return await this.indexInitialization; } + const sessionIndexTemplateName = `${this.options.kibanaIndexName}_security_session_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`; return (this.indexInitialization = new Promise(async (resolve) => { // Check if required index template exists. let indexTemplateExists = false; try { indexTemplateExists = await this.options.clusterClient.callAsInternalUser( 'indices.existsTemplate', - { name: SESSION_INDEX_TEMPLATE_NAME } + { name: sessionIndexTemplateName } ); } catch (err) { this.options.logger.error( @@ -301,8 +296,8 @@ export class SessionIndex { } else { try { await this.options.clusterClient.callAsInternalUser('indices.putTemplate', { - name: SESSION_INDEX_TEMPLATE_NAME, - body: getSessionIndexTemplate(this.options.indexName), + name: sessionIndexTemplateName, + body: getSessionIndexTemplate(this.indexName), }); this.options.logger.debug('Successfully created session index template.'); } catch (err) { @@ -316,7 +311,7 @@ export class SessionIndex { let indexExists = false; try { indexExists = await this.options.clusterClient.callAsInternalUser('indices.exists', { - index: this.options.indexName, + index: this.indexName, }); } catch (err) { this.options.logger.error(`Failed to check if session index exists: ${err.message}`); @@ -329,7 +324,7 @@ export class SessionIndex { } else { try { await this.options.clusterClient.callAsInternalUser('indices.create', { - index: this.options.indexName, + index: this.indexName, }); this.options.logger.debug('Successfully created session index.'); } catch (err) { @@ -378,7 +373,7 @@ export class SessionIndex { try { const response = await this.options.clusterClient.callAsInternalUser('deleteByQuery', { - index: SESSION_INDEX_ALIAS, + index: this.indexName, refresh: 'wait_for', ignore: [409, 404], body: { query: { bool: { should: deleteQueries } } }, 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 index c1cc69979149b..327b7069b2fbd 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -10,7 +10,7 @@ import { TaskManagerSetupContract, TaskManagerStartContract } from '../../../tas import { ConfigType } from '../config'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; import { SessionCookie } from './session_cookie'; -import { SessionIndex, SESSION_INDEX_TEMPLATE_VERSION } from './session_index'; +import { SessionIndex } from './session_index'; import { Session } from './session'; export interface SessionManagementServiceSetupParams { @@ -64,7 +64,7 @@ export class SessionManagementService { this.sessionIndex = new SessionIndex({ config, clusterClient, - indexName: `${kibanaIndexName}_security_session_${SESSION_INDEX_TEMPLATE_VERSION}`, + kibanaIndexName, logger: this.logger.get('index'), }); diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 4b635fc92e328..27796872ee802 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -30,17 +30,17 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session' })).hits.total.value; + return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; } describe('Session Idle cleanup', () => { before(async () => { - await es.cluster.health({ index: '.kibana_security_session', waitForStatus: 'green' }); + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); }); beforeEach(async () => { await es.deleteByQuery({ - index: '.kibana_security_session', + index: '.kibana_security_session*', q: '*', waitForCompletion: true, refresh: true, diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 0d2ef9db6d03f..5bfb8c072c87a 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -27,17 +27,17 @@ export default function ({ getService }: FtrProviderContext) { } async function getNumberOfSessionDocuments() { - return (await es.search({ index: '.kibana_security_session' })).hits.total.value; + return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; } describe('Session Lifespan cleanup', () => { before(async () => { - await es.cluster.health({ index: '.kibana_security_session', waitForStatus: 'green' }); + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); }); beforeEach(async () => { await es.deleteByQuery({ - index: '.kibana_security_session', + index: '.kibana_security_session*', q: '*', waitForCompletion: true, refresh: true, From bcbd9c2edcd71bdfc4f771aa47b69ec6ec9446b0 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 10 Aug 2020 09:25:31 +0200 Subject: [PATCH 18/28] Sync PR with the latest upstream ESLint rules. --- x-pack/test/security_functional/login_selector.config.ts | 2 -- x-pack/test/security_functional/oidc.config.ts | 2 -- x-pack/test/security_functional/saml.config.ts | 2 -- 3 files changed, 6 deletions(-) diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index 69517154f2745..48665c93c091a 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -4,8 +4,6 @@ * 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'; diff --git a/x-pack/test/security_functional/oidc.config.ts b/x-pack/test/security_functional/oidc.config.ts index e21fae6d35ea4..5fd59e049a0f4 100644 --- a/x-pack/test/security_functional/oidc.config.ts +++ b/x-pack/test/security_functional/oidc.config.ts @@ -4,8 +4,6 @@ * 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'; diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts index 7f8bf944d0b1f..c47145f8bc039 100644 --- a/x-pack/test/security_functional/saml.config.ts +++ b/x-pack/test/security_functional/saml.config.ts @@ -4,8 +4,6 @@ * 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'; From b40adf6a8050cd146cd8c4f711880983073fbe81 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 11 Aug 2020 12:06:24 +0200 Subject: [PATCH 19/28] Review#4: more comments and logs, use 32 bytes for SID and AAD instead of 256, properly re-schedule cleanup task when cleanup interval changes. --- .../elasticsearch/elasticsearch_service.ts | 3 + .../server/session_management/session.test.ts | 6 +- .../server/session_management/session.ts | 6 +- .../session_management/session_cookie.ts | 2 +- .../session_management/session_index.ts | 7 +- .../session_management_service.test.ts | 108 ++++++++++++++++-- .../session_management_service.ts | 72 +++++++++--- 7 files changed, 169 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts index c67198e69428c..42a83b2e5b527 100644 --- a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -71,6 +71,9 @@ export class ElasticsearchService { start(): ElasticsearchServiceStart { return { clusterClient: this.#clusterClient!, + + // We'll need to get rid of this as soon as Core's Elasticsearch service exposes this + // functionality in the scope of https://github.com/elastic/kibana/issues/41983. watchOnlineStatus$: () => { const RETRY_SCALE_DURATION = 100; const RETRY_TIMEOUT_MAX = 10000; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index 873714b32d4d3..c4d2342df36dc 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -187,8 +187,8 @@ describe('Session', () => { describe('#create', () => { it('creates session value', async () => { - const mockSID = Buffer.from([1, ...Array(255).keys()]).toString('base64'); - const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); + const mockSID = Buffer.from([1, ...Array(31).keys()]).toString('base64'); + const mockAAD = Buffer.from([2, ...Array(31).keys()]).toString('base64'); const mockSessionIndexValue = sessionIndexMock.createValue({ sid: mockSID, @@ -219,7 +219,7 @@ describe('Session', () => { expect(mockSessionIndex.create).toHaveBeenCalledWith({ sid: mockSID, content: - 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCqBD0WihwGK2mc3zjHg70tqUpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMMPFetOkRITDI+NMz7e3JcMofnDboRnvg==', + 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCpgMitlj6jACf9fYYa66WkuUpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMMPFetOkRITDI+NMz7e3JcMofnDboRnvg==', provider: { name: 'basic1', type: 'basic' }, usernameHash: '8ac76453d769d4fd14b3f41ad4933f9bd64321972cd002de9b847e117435b08b', idleTimeoutExpiration: now + 123, diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 0276512c23e0d..f4c82f09a9ed6 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -77,7 +77,7 @@ export interface SessionValueContentToEncrypt { export class Session { /** - * Session timeout in ms. If `null` session will stay active until the browser is closed. + * Session idle timeout in ms. If `null`, a session will stay active until its max lifespan is reached. */ private readonly idleTimeout: Duration | null; @@ -177,8 +177,8 @@ export class Session { > ) { const [sid, aad] = await Promise.all([ - this.randomBytes(256).then((sidBuffer) => sidBuffer.toString('base64')), - this.randomBytes(256).then((aadBuffer) => aadBuffer.toString('base64')), + this.randomBytes(32).then((sidBuffer) => sidBuffer.toString('base64')), + this.randomBytes(32).then((aadBuffer) => aadBuffer.toString('base64')), ]); const sessionLogger = this.getLoggerForSID(sid); diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts index 62cd1a69c09f2..b7c8211dbd4c1 100644 --- a/x-pack/plugins/security/server/session_management/session_cookie.ts +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -35,7 +35,7 @@ export interface SessionCookieValue { /** * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. + * active until the max lifespan is reached. */ idleTimeoutExpiration: number | null; diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts index e7a56c65358af..191e71f14d66d 100644 --- a/x-pack/plugins/security/server/session_management/session_index.ts +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -128,8 +128,9 @@ export class SessionIndex { /** * Promise that tracks session index initialization process. We'll need to get rid of this as soon - * as Core provides support for plugin statuses. With this we won't mark Security as `Green` until - * index is fully initialized and hence consumers won't be able to call any API we provide. + * as Core provides support for plugin statuses (https://github.com/elastic/kibana/issues/41983). + * With this we won't mark Security as `Green` until index is fully initialized and hence consumers + * won't be able to call any APIs we provide. */ private indexInitialization?: Promise; @@ -348,6 +349,8 @@ export class SessionIndex { * Trigger a removal of any outdated session values. */ async cleanUp() { + this.options.logger.debug(`Running cleanup routine.`); + const now = Date.now(); // Always try to delete sessions with expired lifespan (even if it's not configured right now). diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index ef6afc18a083d..df528e3f97cb4 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -122,25 +122,95 @@ describe('SessionManagementService', () => { ).toBeUndefined(); }); - it('initializes session index when Elasticsearch goes online', async () => { + it('initializes session index and schedules session index cleanup task when Elasticsearch goes online', async () => { const mockStatusSubject = new Subject(); service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); // ES isn't online yet. expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); const mockScheduleRetry = jest.fn(); mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); await nextTick(); expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); await nextTick(); expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); expect(mockScheduleRetry).not.toHaveBeenCalled(); }); + it('removes old cleanup task if cleanup interval changes', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockTaskManager.get.mockResolvedValue({ schedule: { interval: '2000s' } } as any); + + // ES isn't online yet. + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + + expect(mockTaskManager.get).toHaveBeenCalledTimes(1); + expect(mockTaskManager.get).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.remove).toHaveBeenCalledTimes(1); + expect(mockTaskManager.remove).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + }); + + it('does not remove old cleanup task if cleanup interval does not change', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockTaskManager.get.mockResolvedValue({ schedule: { interval: '3600s' } } as any); + + // ES isn't online yet. + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + + expect(mockTaskManager.get).toHaveBeenCalledTimes(1); + expect(mockTaskManager.get).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.remove).not.toHaveBeenCalled(); + + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + }); + it('schedules retry if index initialization fails', async () => { const mockStatusSubject = new Subject(); service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); @@ -151,12 +221,14 @@ describe('SessionManagementService', () => { mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); await nextTick(); expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); expect(mockScheduleRetry).toHaveBeenCalledTimes(1); // Still fails. mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); await nextTick(); expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); expect(mockScheduleRetry).toHaveBeenCalledTimes(2); // And finally succeeds, retry is not scheduled. @@ -165,22 +237,38 @@ describe('SessionManagementService', () => { mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); await nextTick(); expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(3); expect(mockScheduleRetry).toHaveBeenCalledTimes(2); }); - it('schedules session index cleanup task', async () => { + it('schedules retry if cleanup task registration fails', async () => { const mockStatusSubject = new Subject(); service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + mockTaskManager.ensureScheduled.mockRejectedValue(new Error('ugh :/')); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); - expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ - id: SESSION_INDEX_CLEANUP_TASK_NAME, - taskType: SESSION_INDEX_CLEANUP_TASK_NAME, - scope: ['security'], - schedule: { interval: '3600s' }, - params: {}, - state: {}, - }); + expect(mockScheduleRetry).toHaveBeenCalledTimes(1); + + // Still fails. + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + + // And finally succeeds, retry is not scheduled. + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(3); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); }); }); 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 index 327b7069b2fbd..6691b47638e27 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -5,7 +5,12 @@ */ import { Observable, Subscription } from 'rxjs'; -import { HttpServiceSetup, ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { + HttpServiceSetup, + ILegacyClusterClient, + Logger, + SavedObjectsErrorHelpers, +} from '../../../../../src/core/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../../task_manager/server'; import { ConfigType } from '../config'; import { OnlineStatusRetryScheduler } from '../elasticsearch'; @@ -90,25 +95,11 @@ export class SessionManagementService { start({ online$, taskManager }: SessionManagementServiceStartParams) { this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { try { - await this.sessionIndex.initialize(); + await Promise.all([this.sessionIndex.initialize(), this.scheduleCleanupTask(taskManager)]); } catch (err) { scheduleRetry(); } }); - - taskManager - .ensureScheduled({ - id: SESSION_INDEX_CLEANUP_TASK_NAME, - taskType: SESSION_INDEX_CLEANUP_TASK_NAME, - scope: ['security'], - schedule: { interval: `${this.config.session.cleanupInterval.asSeconds()}s` }, - params: {}, - state: {}, - }) - .then( - () => this.logger.debug('Successfully scheduled session index cleanup task.'), - (err) => this.logger.error(`Failed to register session index cleanup task: ${err.message}`) - ); } stop() { @@ -117,4 +108,53 @@ export class SessionManagementService { this.statusSubscription = undefined; } } + + private async scheduleCleanupTask(taskManager: TaskManagerStartContract) { + let currentTask; + try { + currentTask = await taskManager.get(SESSION_INDEX_CLEANUP_TASK_NAME); + } catch (err) { + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + this.logger.error(`Failed to retrieve session index cleanup task: ${err.message}`); + throw err; + } + + this.logger.debug('Session index cleanup task is not scheduled yet.'); + } + + // Check if currently scheduled task is scheduled with the correct interval. + const cleanupInterval = `${this.config.session.cleanupInterval.asSeconds()}s`; + if (currentTask && currentTask.schedule?.interval !== cleanupInterval) { + this.logger.debug( + 'Session index cleanup interval has changed, the cleanup task will be rescheduled.' + ); + + try { + await taskManager.remove(SESSION_INDEX_CLEANUP_TASK_NAME); + } catch (err) { + // We may have multiple instances of Kibana that are removing old task definition at the + // same time. If we get 404 here then task was removed by another instance, it's fine. + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + this.logger.error(`Failed to remove old session index cleanup task: ${err.message}`); + throw err; + } + } + } + + try { + await taskManager.ensureScheduled({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: cleanupInterval }, + params: {}, + state: {}, + }); + } catch (err) { + this.logger.error(`Failed to register session index cleanup task: ${err.message}`); + throw err; + } + + this.logger.debug('Successfully scheduled session index cleanup task.'); + } } From 9548184103a930e94e14679750eeb571923722c8 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 11 Aug 2020 15:16:18 +0200 Subject: [PATCH 20/28] Review#4: properly handle empty sessions in `SessionTimeout` service, restore support of preserving current URL when session expires and `provider` query string parameter is specified. --- .../public/session/session_timeout.test.tsx | 8 +++ .../public/session/session_timeout.tsx | 5 ++ .../authentication/authenticator.test.ts | 19 ++++++ .../server/authentication/authenticator.ts | 4 +- .../authentication/providers/basic.test.ts | 6 ++ .../server/authentication/providers/basic.ts | 4 +- .../authentication/providers/kerberos.test.ts | 8 ++- .../authentication/providers/kerberos.ts | 16 +++-- .../authentication/providers/oidc.test.ts | 17 ++++-- .../server/authentication/providers/oidc.ts | 58 ++++++++++--------- .../authentication/providers/pki.test.ts | 8 ++- .../server/authentication/providers/pki.ts | 16 +++-- .../authentication/providers/saml.test.ts | 13 ++++- .../server/authentication/providers/saml.ts | 52 +++++++++-------- .../authentication/providers/token.test.ts | 8 ++- .../server/authentication/providers/token.ts | 16 +++-- .../routes/session_management/info.test.ts | 2 +- .../server/routes/session_management/info.ts | 28 ++++----- 18 files changed, 190 insertions(+), 98 deletions(-) diff --git a/x-pack/plugins/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx index 11aadcff377ef..83a6456c71d93 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -177,6 +177,14 @@ describe('Session Timeout', () => { expect(sessionTimeout['sessionInfo']).toBeUndefined(); expect(method).not.toHaveBeenCalled(); }); + + test(`handles empty response`, async () => { + http.fetch.mockResolvedValue(''); + await sessionTimeout.start(); + + expect(http.fetch).toHaveBeenCalledTimes(1); + expect(sessionExpired.logout).toHaveBeenCalled(); + }); }); describe('warning toast', () => { diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx index b06d8fffd4b62..849267de289c2 100644 --- a/x-pack/plugins/security/public/session/session_timeout.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -105,7 +105,12 @@ export class SessionTimeout implements ISessionTimeout { private fetchSessionInfoAndResetTimers = async (extend = false) => { const method = extend ? 'POST' : 'GET'; try { + // If session doesn't exist anymore we should log user out. const result = await this.http.fetch(SESSION_ROUTE, { method, asSystemRequest: !extend }); + if (!result) { + this.sessionExpired.logout(); + return; + } this.handleSessionInfoAndResetTimers(result); diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 5a145d3e6a772..fcc652505ba3a 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -1743,6 +1743,24 @@ describe('Authenticator', () => { ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request, null); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); + }); + + it('if session does not exist and provider name is not available, returns whatever authentication provider returns.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.logout.mockResolvedValue( + DeauthenticationResult.redirectTo('some-url') + ); + + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.redirectTo('some-url') + ); + + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request); expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); @@ -1754,6 +1772,7 @@ describe('Authenticator', () => { DeauthenticationResult.notHandled() ); + expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 3e4e155ffbf4c..1fb9d9221f041 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -356,7 +356,9 @@ export class Authenticator { const sessionValue = await this.getSessionValue(request); if (sessionValue) { await this.session.clear(request); - return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); + return this.providers + .get(sessionValue.provider.name)! + .logout(request, sessionValue.state ?? null); } const queryStringProviderName = (request.query as Record)?.provider; diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 22d10d1cec347..2481844abb389 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -184,6 +184,12 @@ describe('BasicAuthenticationProvider', () => { ); }); + it('redirects to login view if state is `null`.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') + ); + }); + it('always redirects to the login page.', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 83d4ea689f46a..35ab2d242659a 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -121,7 +121,9 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { return DeauthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index f04506eb01593..839b5c991f09b 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -496,8 +496,14 @@ describe('KerberosAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 44919fae9ced8..5b593851cc2f2 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -102,16 +102,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + return DeauthenticationResult.failed(err); + } } return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); 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 83e172c419a7d..81e9ecb8a377b 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -605,19 +605,26 @@ describe('OIDCAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented or does not include access token.', async () => { + it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, undefined as any)).resolves.toEqual( DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, {} as any)).resolves.toEqual( - DeauthenticationResult.notHandled() - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + it('redirects to logged out view if state is `null` or does not include access token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) + ); await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 8497bcc6ba46c..75c909cdcd94b 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -417,43 +417,47 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async logout(request: KibanaRequest, state: ProviderState) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state || !state.accessToken) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - const logoutBody = { - body: { - token: state.accessToken, - refresh_token: state.refreshToken, - }, - }; - // 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/logout`. - const { redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcLogout', - logoutBody - ); + if (state?.accessToken) { + try { + const logoutBody = { + body: { + token: state.accessToken, + refresh_token: state.refreshToken, + }, + }; + // 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/logout`. + const { redirect } = await this.options.client.callAsInternalUser( + 'shield.oidcLogout', + logoutBody + ); - this.logger.debug('User session has been successfully invalidated.'); + this.logger.debug('User session has been successfully invalidated.'); - // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration - // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect - // Provider to properly complete logout. - if (redirect != null) { - this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); - return DeauthenticationResult.redirectTo(redirect); + // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration + // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect + // Provider to properly complete logout. + if (redirect != null) { + this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); + return DeauthenticationResult.redirectTo(redirect); + } + } catch (err) { + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + return DeauthenticationResult.failed(err); } - - return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); - } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); - return DeauthenticationResult.failed(err); } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index fec03c5d04b0d..053d20e37b39e 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -527,8 +527,14 @@ describe('PKIAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 164a9516f0695..f3cc21500df26 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -107,16 +107,20 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no access token to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - await this.options.tokens.invalidate({ accessToken: state.accessToken }); - } catch (err) { - this.logger.debug(`Failed invalidating access token: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate({ accessToken: state.accessToken }); + } catch (err) { + this.logger.debug(`Failed invalidating access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } } return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); 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 16043526fcd75..75eb7ae93f360 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -1019,11 +1019,18 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - await expect(provider.logout(request, {} as any)).resolves.toEqual( - DeauthenticationResult.notHandled() + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null` or does not include access token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); await expect(provider.logout(request, { somethingElse: 'x' } as any)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 71e2a862b7b61..cf6772332b8b6 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -231,7 +231,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async logout(request: KibanaRequest, state?: ProviderState) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); // Normally when there is no active session in Kibana, `logout` method shouldn't do anything @@ -249,36 +249,38 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // redirect to the `loggedOut` URL instead. const isIdPInitiatedSLORequest = isSAMLRequestQuery(request.query); const isSPInitiatedSLOResponse = isSAMLResponseQuery(request.query); - if (!state?.accessToken && !isIdPInitiatedSLORequest && !isSPInitiatedSLOResponse) { + if (state === undefined && !isIdPInitiatedSLORequest && !isSPInitiatedSLOResponse) { this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - // It may _theoretically_ (highly unlikely in practice though) happen that when user receives - // logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true - // and state !== undefined). In this case case it'd be safer to trigger SP initiated logout - // for the new session as well. - const redirect = isIdPInitiatedSLORequest - ? await this.performIdPInitiatedSingleLogout(request) - : state - ? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!) - : // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901 - null; - - // Having non-null `redirect` field within logout response means that IdP - // supports SAML Single Logout and we should redirect user to the specified - // location to properly complete logout. - if (redirect != null) { - this.logger.debug('Redirecting user to Identity Provider to complete logout.'); - return DeauthenticationResult.redirectTo(redirect); + if (state?.accessToken || isIdPInitiatedSLORequest || isSPInitiatedSLOResponse) { + try { + // It may _theoretically_ (highly unlikely in practice though) happen that when user receives + // logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true + // and state !== undefined). In this case case it'd be safer to trigger SP initiated logout + // for the new session as well. + const redirect = isIdPInitiatedSLORequest + ? await this.performIdPInitiatedSingleLogout(request) + : state + ? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!) + : // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901 + null; + + // Having non-null `redirect` field within logout response means that IdP + // supports SAML Single Logout and we should redirect user to the specified + // location to properly complete logout. + if (redirect != null) { + this.logger.debug('Redirecting user to Identity Provider to complete logout.'); + return DeauthenticationResult.redirectTo(redirect); + } + } catch (err) { + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + return DeauthenticationResult.failed(err); } - - return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); - } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); - return DeauthenticationResult.failed(err); } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index f83331d84e43c..0264edf4fc082 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -427,8 +427,14 @@ describe('TokenAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to login view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index abf4c293c4c53..869fd69173e2e 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -128,17 +128,21 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } this.logger.debug('Token-based logout has been initiated by the user.'); - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating user's access token: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } } const queryString = request.url.search || `?msg=LOGGED_OUT`; diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts index f7d9f9bbe1272..87b31113426d2 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -98,7 +98,7 @@ describe('Info session routes', () => { httpServerMock.createKibanaRequest(), kibanaResponseFactory ) - ).resolves.toEqual({ status: 200, options: {} }); + ).resolves.toEqual({ status: 204, options: {} }); }); }); }); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 118b5a403d8ea..41669f5fefbd2 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -21,20 +21,20 @@ export function defineSessionInfoRoutes({ router, logger, session }: RouteDefini 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, - } - : {} - ); + if (sessionValue) { + return response.ok({ + 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, + }); + } + + return response.noContent(); } catch (err) { logger.error(`Error retrieving user session: ${err.message}`); return response.internalError(); From 2d3ea3299b21bc6153af137e5776951f95b155a1 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 11 Aug 2020 18:40:11 +0200 Subject: [PATCH 21/28] Review#5: allow smaller cleanup timeouts in dev mode (for tests). --- x-pack/plugins/security/server/config.test.ts | 2 +- x-pack/plugins/security/server/config.ts | 21 ++++++++++++------- .../session_idle.config.ts | 4 ++-- .../session_lifespan.config.ts | 4 ++-- .../tests/session_idle/cleanup.ts | 20 +++++++++--------- .../tests/session_lifespan/cleanup.ts | 8 +++---- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index c4b1e6a22111a..f9ed735810cb1 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -793,7 +793,7 @@ describe('config schema', () => { describe('session', () => { it('should throw error if xpack.security.session.cleanupInterval is less than 1 minute', () => { expect(() => - ConfigSchema.validate({ session: { cleanupInterval: '59s' } }) + ConfigSchema.validate({ session: { cleanupInterval: '59s' } }, { dist: true }) ).toThrowErrorMatchingInlineSnapshot( `"[session.cleanupInterval]: the value must be greater or equal to 1 minute."` ); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 28098e320021e..f308321ed6cbd 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -149,14 +149,19 @@ export const ConfigSchema = schema.object({ session: schema.object({ idleTimeout: schema.nullable(schema.duration()), lifespan: schema.nullable(schema.duration()), - cleanupInterval: schema.duration({ - defaultValue: '1h', - validate(value) { - if (value.asSeconds() < 60) { - return 'the value must be greater or equal to 1 minute.'; - } - }, - }), + cleanupInterval: schema.conditional( + schema.contextRef('dist'), + true, + schema.duration({ + defaultValue: '1h', + validate(value) { + if (value.asSeconds() < 60) { + return 'the value must be greater or equal to 1 minute.'; + } + }, + }), + schema.duration({ defaultValue: '1h' }) + ), }), secureCookies: schema.boolean({ defaultValue: false }), sameSiteCookies: schema.maybe( diff --git a/x-pack/test/security_api_integration/session_idle.config.ts b/x-pack/test/security_api_integration/session_idle.config.ts index 467a2880f125f..da85c6342037e 100644 --- a/x-pack/test/security_api_integration/session_idle.config.ts +++ b/x-pack/test/security_api_integration/session_idle.config.ts @@ -30,8 +30,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), - '--xpack.security.session.idleTimeout=15s', - '--xpack.security.session.cleanupInterval=60s', + '--xpack.security.session.idleTimeout=5s', + '--xpack.security.session.cleanupInterval=10s', ], }, diff --git a/x-pack/test/security_api_integration/session_lifespan.config.ts b/x-pack/test/security_api_integration/session_lifespan.config.ts index d7f5a6c17380c..17773a7739847 100644 --- a/x-pack/test/security_api_integration/session_lifespan.config.ts +++ b/x-pack/test/security_api_integration/session_lifespan.config.ts @@ -30,8 +30,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), - '--xpack.security.session.lifespan=20s', - '--xpack.security.session.cleanupInterval=60s', + '--xpack.security.session.lifespan=5s', + '--xpack.security.session.cleanupInterval=10s', ], }, diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 27796872ee802..56dfbe6a77023 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -7,13 +7,13 @@ import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; -import { ToolingLog } from '@kbn/dev-utils'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const es = getService('legacyEs'); const config = getService('config'); + const log = getService('log'); const [username, password] = config.get('servers.elasticsearch.auth').split(':'); async function checkSessionCookie(sessionCookie: Cookie, providerName: string) { @@ -49,7 +49,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('should properly clean up session expired because of idle timeout', async function () { - this.timeout(180000); + this.timeout(60000); const response = await supertest .post('/internal/security/login') @@ -66,9 +66,10 @@ export default function ({ getService }: FtrProviderContext) { await checkSessionCookie(sessionCookie, 'basic'); expect(await getNumberOfSessionDocuments()).to.be(1); - // Cleanup routine runs every 60s, let's wait for 120s to make sure it runs when idle timeout + // Cleanup routine runs every 10s, and idle timeout threshold is three times larger than 5s + // idle timeout, let's wait for 30s to make sure cleanup routine runs when idle timeout // threshold is exceeded. - await delay(120000); + await delay(30000); // Session info is removed from the index and cookie isn't valid anymore expect(await getNumberOfSessionDocuments()).to.be(0); @@ -80,7 +81,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('should not clean up session if user is active', async function () { - this.timeout(180000); + this.timeout(60000); const response = await supertest .post('/internal/security/login') @@ -97,15 +98,14 @@ export default function ({ getService }: FtrProviderContext) { await checkSessionCookie(sessionCookie, 'basic'); expect(await getNumberOfSessionDocuments()).to.be(1); - // Run 15 consequent requests with 10s delay, during this time cleanup procedure should run at + // Run 20 consequent requests with 1.5s delay, during this time cleanup procedure should run at // least twice. - const log = new ToolingLog({ level: 'info', writeTo: process.stdout }); - for (const counter of [...Array(15).keys()]) { + for (const counter of [...Array(20).keys()]) { // Session idle timeout is 15s, let's wait 10s and make a new request that would extend the session. - await delay(10000); + await delay(1500); sessionCookie = await checkSessionCookie(sessionCookie, 'basic'); - log.info(`Session is still valid after ${(counter + 1) * 10}s`); + log.debug(`Session is still valid after ${(counter + 1) * 1.5}s`); } // Session document should still be present. diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 5bfb8c072c87a..8cee7c720df14 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('should properly clean up session expired because of lifespan', async function () { - this.timeout(180000); + this.timeout(60000); const response = await supertest .post('/internal/security/login') @@ -63,9 +63,9 @@ export default function ({ getService }: FtrProviderContext) { await checkSessionCookie(sessionCookie, 'basic'); expect(await getNumberOfSessionDocuments()).to.be(1); - // Cleanup routine runs every 60s, let's wait for 120s to make sure it runs when lifespan is - // exceeded. - await delay(120000); + // Cleanup routine runs every 10s, let's wait for 30s to make sure it runs multiple times and + // when lifespan is exceeded. + await delay(30000); // Session info is removed from the index and cookie isn't valid anymore expect(await getNumberOfSessionDocuments()).to.be(0); From 21d24ed969b424ac12ef3b52fbc7bb244e899f0b Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 12 Aug 2020 12:34:27 +0200 Subject: [PATCH 22/28] Review#5: decrease minimum value for cleanup interval to 10s to make test faster, make session info route require authentication. --- docs/settings/security-settings.asciidoc | 2 +- .../public/session/session_timeout.test.tsx | 8 ------- .../public/session/session_timeout.tsx | 5 ----- x-pack/plugins/security/server/config.test.ts | 6 +++--- x-pack/plugins/security/server/config.ts | 21 +++++++------------ .../routes/session_management/info.test.ts | 2 +- .../server/routes/session_management/info.ts | 7 +------ .../server/session_management/session.ts | 13 ++++++++++-- 8 files changed, 25 insertions(+), 39 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 83a76988a7c2f..d081a089a0e9f 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -238,7 +238,7 @@ string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). |=== | `xpack.security.session.cleanupInterval` -| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default this value is 1 hour. The minimum value is 1 minute. +| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default this value is 1 hour. The minimum value is 10 seconds. |=== diff --git a/x-pack/plugins/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx index 83a6456c71d93..11aadcff377ef 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -177,14 +177,6 @@ describe('Session Timeout', () => { expect(sessionTimeout['sessionInfo']).toBeUndefined(); expect(method).not.toHaveBeenCalled(); }); - - test(`handles empty response`, async () => { - http.fetch.mockResolvedValue(''); - await sessionTimeout.start(); - - expect(http.fetch).toHaveBeenCalledTimes(1); - expect(sessionExpired.logout).toHaveBeenCalled(); - }); }); describe('warning toast', () => { diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx index 849267de289c2..b06d8fffd4b62 100644 --- a/x-pack/plugins/security/public/session/session_timeout.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -105,12 +105,7 @@ export class SessionTimeout implements ISessionTimeout { private fetchSessionInfoAndResetTimers = async (extend = false) => { const method = extend ? 'POST' : 'GET'; try { - // If session doesn't exist anymore we should log user out. const result = await this.http.fetch(SESSION_ROUTE, { method, asSystemRequest: !extend }); - if (!result) { - this.sessionExpired.logout(); - return; - } this.handleSessionInfoAndResetTimers(result); diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index f9ed735810cb1..520081ae30d8d 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -791,11 +791,11 @@ describe('config schema', () => { }); describe('session', () => { - it('should throw error if xpack.security.session.cleanupInterval is less than 1 minute', () => { + it('should throw error if xpack.security.session.cleanupInterval is less than 10 seconds', () => { expect(() => - ConfigSchema.validate({ session: { cleanupInterval: '59s' } }, { dist: true }) + ConfigSchema.validate({ session: { cleanupInterval: '9s' } }) ).toThrowErrorMatchingInlineSnapshot( - `"[session.cleanupInterval]: the value must be greater or equal to 1 minute."` + `"[session.cleanupInterval]: the value must be greater or equal to 10 seconds."` ); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index f308321ed6cbd..dcfe4825fb035 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -149,19 +149,14 @@ export const ConfigSchema = schema.object({ session: schema.object({ idleTimeout: schema.nullable(schema.duration()), lifespan: schema.nullable(schema.duration()), - cleanupInterval: schema.conditional( - schema.contextRef('dist'), - true, - schema.duration({ - defaultValue: '1h', - validate(value) { - if (value.asSeconds() < 60) { - return 'the value must be greater or equal to 1 minute.'; - } - }, - }), - schema.duration({ defaultValue: '1h' }) - ), + cleanupInterval: schema.duration({ + defaultValue: '1h', + validate(value) { + if (value.asSeconds() < 10) { + return 'the value must be greater or equal to 10 seconds.'; + } + }, + }), }), secureCookies: schema.boolean({ defaultValue: false }), sameSiteCookies: schema.maybe( diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts index 87b31113426d2..fa9cba61df018 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.test.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -42,7 +42,7 @@ describe('Info session routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: 'optional' }); + expect(routeConfig.options).toBeUndefined(); expect(routeConfig.validate).toBe(false); }); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts index 41669f5fefbd2..381127284f780 100644 --- a/x-pack/plugins/security/server/routes/session_management/info.ts +++ b/x-pack/plugins/security/server/routes/session_management/info.ts @@ -12,12 +12,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { router.get( - { - path: '/internal/security/session', - validate: false, - // We have both authenticated and non-authenticated sessions. - options: { authRequired: 'optional' }, - }, + { path: '/internal/security/session', validate: false }, async (_context, request, response) => { try { const sessionValue = await session.get(request); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index f4c82f09a9ed6..57c6509147665 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -75,6 +75,15 @@ export interface SessionValueContentToEncrypt { state: unknown; } +/** + * The SIDs and AAD must be unpredictable to prevent guessing attacks, where an attacker is able to + * guess or predict the ID of a valid session through statistical analysis techniques. That's why we + * generate SIDs and AAD using a secure PRNG and current OWASP guidance suggests a minimum of 16 + * bytes (128 bits), but to be on the safe side we decided to use 32 bytes (256 bits). + */ +const SID_BYTE_LENGTH = 32; +const AAD_BYTE_LENGTH = 32; + export class Session { /** * Session idle timeout in ms. If `null`, a session will stay active until its max lifespan is reached. @@ -177,8 +186,8 @@ export class Session { > ) { const [sid, aad] = await Promise.all([ - this.randomBytes(32).then((sidBuffer) => sidBuffer.toString('base64')), - this.randomBytes(32).then((aadBuffer) => aadBuffer.toString('base64')), + this.randomBytes(SID_BYTE_LENGTH).then((sidBuffer) => sidBuffer.toString('base64')), + this.randomBytes(AAD_BYTE_LENGTH).then((aadBuffer) => aadBuffer.toString('base64')), ]); const sessionLogger = this.getLoggerForSID(sid); From efdbef236bd3ebf112ba3323ff7f2ae233fb86e5 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 12 Aug 2020 17:22:22 +0200 Subject: [PATCH 23/28] Update docs. --- docs/settings/security-settings.asciidoc | 9 ++- docs/setup/production.asciidoc | 2 +- .../security/authentication/index.asciidoc | 20 ------ docs/user/security/securing-kibana.asciidoc | 31 +--------- .../user/security/session-management.asciidoc | 62 +++++++++++++++++++ .../resources/bin/kibana-docker | 1 + 6 files changed, 70 insertions(+), 55 deletions(-) create mode 100644 docs/user/security/session-management.asciidoc diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index d081a089a0e9f..dda592128b3bd 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -125,8 +125,8 @@ In addition to <.maxRedirectURLSize` -| The maximum size of the URL that {kib} is allowed to store during the authentication SAML handshake. For more information, refer to <>. +`saml..useRelayStateDeepLink` +| Determines if provider should treat `RelayState` parameter as a deep link in {kib} during Identity Provider initiated login. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL, e.g. for `/app/dashboards#/list` link `RelayState` parameter would look like this `RelayState=%2Fapp%2Fdashboards%23%2Flist` for . |=== @@ -186,8 +186,7 @@ You can configure the following settings in the `kibana.yml` file. | Sets the name of the cookie used for the session. The default value is `"sid"`. | `xpack.security.encryptionKey` - | An arbitrary string of 32 characters or more that is used to encrypt credentials - in a cookie. It is crucial that this key is not exposed to users of {kib}. By + | An arbitrary string of 32 characters or more that is used to encrypt session information. It is crucial that this key is not exposed to users of {kib}. By default, a value is automatically generated in memory. If you use that default behavior, all sessions are invalidated when {kib} restarts. In addition, high-availability deployments of {kib} will behave unexpectedly @@ -238,7 +237,7 @@ string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). |=== | `xpack.security.session.cleanupInterval` -| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default this value is 1 hour. The minimum value is 10 seconds. +| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour. The minimum value is 10 seconds. |=== diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index 23dbb3346b7ef..3075220e3a47c 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -132,7 +132,7 @@ server.port Settings that must be the same: -------- -xpack.security.encryptionKey //decrypting session cookies +xpack.security.encryptionKey //decrypting session information xpack.reporting.encryptionKey //decrypting reports xpack.encryptedSavedObjects.encryptionKey // decrypting saved objects -------- diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index fe93e38151b82..34b4669b8ab65 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -181,26 +181,6 @@ Basic authentication is supported _only_ if the `basic` authentication provider To support basic authentication for the applications like `curl` or when the `Authorization: Basic base64(username:password)` HTTP header is included in the request (for example, by reverse proxy), add `Basic` scheme to the list of supported schemes for the <>. -[float] -[[security-saml-and-long-urls]] -===== SAML and long URLs - -At the beginning of the SAML handshake, {kib} stores the initial URL in the session cookie, so it can redirect the user back to that URL after successful SAML authentication. -If the URL is long, the session cookie might exceed the maximum size supported by the browser--typically 4KB for all cookies per domain. When this happens, the session cookie is truncated, -or dropped completely, and you might experience sporadic failures during SAML authentication. - -To remedy this issue, you can decrease the maximum -size of the URL that {kib} is allowed to store during the SAML handshake. The default value is 2KB. - -[source,yaml] --------------------------------------------------------------------------------- -xpack.security.authc.providers: - saml.saml1: - order: 0 - realm: saml1 - maxRedirectURLSize: 1kb --------------------------------------------------------------------------------- - [[oidc]] ==== OpenID Connect single sign-on diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 0177ac94bd402..3599660645259 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -56,35 +56,7 @@ xpack.security.encryptionKey: "something_at_least_32_characters" For more information, see <>. -- -. Optional: Set a timeout to expire idle sessions. By default, a session stays -active until the browser is closed. To define a sliding session expiration, set -the `xpack.security.session.idleTimeout` property in the `kibana.yml` -configuration file. The idle timeout is formatted as a duration of -`[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set -the idle timeout to expire idle sessions after 10 minutes: -+ --- -[source,yaml] --------------------------------------------------------------------------------- -xpack.security.session.idleTimeout: "10m" --------------------------------------------------------------------------------- --- - -. Optional: Change the maximum session duration or "lifespan" -- also known as -the "absolute timeout". By default, a session stays active until the browser is -closed. If an idle timeout is defined, a session can still be extended -indefinitely. To define a maximum session lifespan, set the -`xpack.security.session.lifespan` property in the `kibana.yml` configuration -file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). For example, set the lifespan to expire -sessions after 8 hours: -+ --- -[source,yaml] --------------------------------------------------------------------------------- -xpack.security.session.lifespan: "8h" --------------------------------------------------------------------------------- --- +. Optional: <>. . Optional: <>. @@ -146,3 +118,4 @@ include::securing-communications/index.asciidoc[] include::securing-communications/elasticsearch-mutual-tls.asciidoc[] include::audit-logging.asciidoc[] include::access-agreement.asciidoc[] +include::session-management.asciidoc[] diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc new file mode 100644 index 0000000000000..29fb7a704e06f --- /dev/null +++ b/docs/user/security/session-management.asciidoc @@ -0,0 +1,62 @@ +[role="xpack"] +[[xpack-security-session-management]] +=== Session management + +When you log in to {kib} it creates a session that is used to authenticate any subsequent request to {kib} made on your behalf. {kib} encrypts any sensitive session information and stores it in a dedicated hidden {es} index. By default, the name of that index is `.kibana_security_session_1` where the prefix depends on the name of the main `.kibana` index. + +Additionally, for every new session {kib} creates an encrypted client side cookie that is stored in your browser and sent to {kib} with every request. This way {kib} can associate request with the session information stored in the session index. + +When your session expires, or you log out of {kib} explicitly it will invalidate your cookie and remove session information from the index. In addition to that {kib} performs a regular session index cleanup to remove any expired sessions that weren't invalidated explicitly. + +[[session-idle-timeout]] +==== Session idle timeout + +You can configure timeout to expire idle sessions. By default, a session stays +active until the browser is closed. To define a sliding session expiration, set +the `xpack.security.session.idleTimeout` property in the `kibana.yml` +configuration file. The idle timeout is formatted as a duration of +`[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set +the idle timeout to expire idle sessions after 10 minutes: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.idleTimeout: "10m" +-------------------------------------------------------------------------------- +-- + +[[session-lifespan]] +==== Session lifespan + +You can configure the maximum session duration or "lifespan" -- also known as +the "absolute timeout". By default, a session stays active until the browser is +closed. If an idle timeout is defined, a session can still be extended +indefinitely. To define a maximum session lifespan, set the +`xpack.security.session.lifespan` property in the `kibana.yml` configuration +file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` +(e.g. '70ms', '5s', '3d', '1Y'). For example, set the lifespan to expire +sessions after 8 hours: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.lifespan: "8h" +-------------------------------------------------------------------------------- +-- + +[[session-cleanup-interval]] +==== Session cleanup interval + +You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70000ms', '50s', '3d', '1Y'). For example, schedule session index cleanup to be performed only once a day: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.cleanupInterval: "1d" +-------------------------------------------------------------------------------- +-- + +[IMPORTANT] +============================================================================ +If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out of {kib}. This may lead to an infinitely growing session index. It's one of the reasons why we strongly recommend configuring idle timeout and lifespan settings for the {kib} sessions so that they can be eventually cleaned up even if you don't log out of {kib} explicitly. +============================================================================ \ No newline at end of file diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 0913d4ba4e83a..6ff922394e61a 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -235,6 +235,7 @@ kibana_vars=( xpack.security.sessionTimeout xpack.security.session.idleTimeout xpack.security.session.lifespan + xpack.security.session.cleanupInterval xpack.security.loginAssistanceMessage xpack.security.loginHelp xpack.spaces.enabled From 378c23968779cd6b33802759c38e545d7a18025b Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 14 Aug 2020 11:03:13 +0200 Subject: [PATCH 24/28] Review#6: incorporate docs review suggestions. --- docs/user/security/session-management.asciidoc | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index 29fb7a704e06f..7c6f27ae3fd7e 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -2,21 +2,19 @@ [[xpack-security-session-management]] === Session management -When you log in to {kib} it creates a session that is used to authenticate any subsequent request to {kib} made on your behalf. {kib} encrypts any sensitive session information and stores it in a dedicated hidden {es} index. By default, the name of that index is `.kibana_security_session_1` where the prefix depends on the name of the main `.kibana` index. +When you log in to {kib} it creates a session that is used to authenticate subsequent requests to {kib}. A session consists of two components: an encrypted cookie that is stored in your browser, and an encrypted document in a dedicated {es} hidden index. By default, the name of that index is `.kibana_security_session_1` where the prefix is derived from {kib}'s primary `.kibana` index. -Additionally, for every new session {kib} creates an encrypted client side cookie that is stored in your browser and sent to {kib} with every request. This way {kib} can associate request with the session information stored in the session index. - -When your session expires, or you log out of {kib} explicitly it will invalidate your cookie and remove session information from the index. In addition to that {kib} performs a regular session index cleanup to remove any expired sessions that weren't invalidated explicitly. +When your session expires, or you log out of {kib} explicitly it will invalidate your cookie and remove session information from the index. {kib} also periodically invalidates and removes any expired sessions that weren't invalidated explicitly. [[session-idle-timeout]] ==== Session idle timeout -You can configure timeout to expire idle sessions. By default, a session stays +You can optionally expire sessions after a period of inactivity. By default, a session stays active until the browser is closed. To define a sliding session expiration, set the `xpack.security.session.idleTimeout` property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set -the idle timeout to expire idle sessions after 10 minutes: +the idle timeout to expire idle sessions after 10 minutes of inactivity: -- [source,yaml] From a4cdb6040b0119978ceef002661cd0f24fa021c5 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 14 Aug 2020 15:02:30 +0200 Subject: [PATCH 25/28] tests: make sure we always wait for the green status before trying to delete anything from session index. --- .../security_api_integration/tests/session_idle/cleanup.ts | 5 +---- .../tests/session_lifespan/cleanup.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 56dfbe6a77023..c4302b7637923 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -34,11 +34,8 @@ export default function ({ getService }: FtrProviderContext) { } describe('Session Idle cleanup', () => { - before(async () => { - await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); - }); - beforeEach(async () => { + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); await es.deleteByQuery({ index: '.kibana_security_session*', q: '*', diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 8cee7c720df14..d9cb671282124 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -31,11 +31,8 @@ export default function ({ getService }: FtrProviderContext) { } describe('Session Lifespan cleanup', () => { - before(async () => { - await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); - }); - beforeEach(async () => { + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); await es.deleteByQuery({ index: '.kibana_security_session*', q: '*', From 3ded572d0d6a90cfe04a86b6ae6a5026b4ea2415 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 17 Aug 2020 10:47:51 +0200 Subject: [PATCH 26/28] Review#7: handle more docs comments. --- docs/settings/security-settings.asciidoc | 65 +++++++------------ .../security/authentication/index.asciidoc | 15 ++--- docs/user/security/securing-kibana.asciidoc | 12 +++- .../user/security/session-management.asciidoc | 32 ++++----- 4 files changed, 56 insertions(+), 68 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index dda592128b3bd..7b389fa0783be 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -126,7 +126,7 @@ In addition to <.useRelayStateDeepLink` -| Determines if provider should treat `RelayState` parameter as a deep link in {kib} during Identity Provider initiated login. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL, e.g. for `/app/dashboards#/list` link `RelayState` parameter would look like this `RelayState=%2Fapp%2Fdashboards%23%2Flist` for . +| Determines if the provider should treat the `RelayState` parameter as a deep link in {kib} during Identity Provider initiated log in. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL. For example, the `/app/dashboards#/list` link in `RelayState` parameter would look like this `RelayState=%2Fapp%2Fdashboards%23%2Flist`. |=== @@ -164,19 +164,25 @@ There is a very limited set of cases when you'd want to change these settings. F |=== [float] -[[login-selector-settings]] -===== Login Selector UI settings +[[login-ui-settings]] +===== Login user interface settings [cols="2*<"] |=== +| `xpack.security.loginAssistanceMessage` +| Adds a message to the login UI. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. + +| `xpack.security.loginHelp` +| Adds a message accessible at the login UI with additional help information for the login process. + | `xpack.security.authc.selector.enabled` -| Determines if the Login Selector UI should be enabled. By default, this setting is set to `true` if more than one authentication provider is configured. +| Determines if the login selector UI should be enabled. By default, this setting is set to `true` if more than one authentication provider is configured. |=== [float] -[[security-ui-settings]] -==== User interface security settings +[[security-session-and-cookie-settings]] +==== Session and cookie security settings You can configure the following settings in the `kibana.yml` file. @@ -186,7 +192,7 @@ You can configure the following settings in the `kibana.yml` file. | Sets the name of the cookie used for the session. The default value is `"sid"`. | `xpack.security.encryptionKey` - | An arbitrary string of 32 characters or more that is used to encrypt session information. It is crucial that this key is not exposed to users of {kib}. By + | An arbitrary string of 32 characters or more that is used to encrypt session information. Do **not** expose this key to users of {kib}. By default, a value is automatically generated in memory. If you use that default behavior, all sessions are invalidated when {kib} restarts. In addition, high-availability deployments of {kib} will behave unexpectedly @@ -204,56 +210,33 @@ You can configure the following settings in the `kibana.yml` file. This is *not set* by default, which modern browsers will treat as `Lax`. If you use Kibana embedded in an iframe in modern browsers, you might need to set it to `None`. Setting this value to `None` requires cookies to be sent over a secure connection by setting `xpack.security.secureCookies: true`. | `xpack.security.session.idleTimeout` - | Sets the session duration. By default, sessions stay active until the - browser is closed. When this is set to an explicit idle timeout, closing the - browser still requires the user to log back in to {kib}. - -|=== + | This setting ensures that user sessions will expire after a period of inactivity. This and `xpack.security.session.lifespan` are both +highly recommended. By default, this setting is not set. +2+a| [TIP] ============ -The format is a string of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '70ms', '5s', '3d', '1Y'). ============ -[cols="2*<"] -|=== - | `xpack.security.session.lifespan` - | Sets the maximum duration, also known as "absolute timeout". By default, - a session can be renewed indefinitely. When this value is set, a session will end - once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` - is not set, this setting will still cause sessions to expire. - -|=== + | This setting ensures that user sessions will expire after the defined time period. This behavior also known as an "absolute timeout". If +this is _not_ set, user sessions could stay active indefinitely. This and `xpack.security.session.idleTimeout` are both highly +recommended. By default, this setting is not set. +2+a| [TIP] ============ -The format is a -string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '70ms', '5s', '3d', '1Y'). ============ -[cols="2*<"] -|=== - | `xpack.security.session.cleanupInterval` | Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour. The minimum value is 10 seconds. -|=== - +2+a| [TIP] ============ -The format is a -string of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). ============ -[cols="2*<"] -|=== - -| `xpack.security.loginAssistanceMessage` - | Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. - -| `xpack.security.loginHelp` - | Adds a message accessible at the Login Selector UI with additional help information for the login process. - |=== diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 34b4669b8ab65..369c881057fce 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -59,8 +59,6 @@ For more information, refer to <> and <> ===== Access and refresh tokens Once the user logs in to {kib} Single Sign-On, either using SAML or OpenID Connect, {es} issues access and refresh tokens -that {kib} encrypts and stores them in its own session cookie. This way, the user isn't redirected to the Identity Provider -for every request that requires authentication. It also means that the {kib} session depends on the <> settings, and the user is automatically logged -out if the session expires. An access token that is stored in the session cookie can expire, in which case {kib} will -automatically renew it with a one-time-use refresh token and store it in the same cookie. +out if the session expires. An access token that is stored in the session can expire, in which case {kib} will +automatically renew it with a one-time-use refresh token and store it in the same session. {kib} can only determine if an access token has expired if it receives a request that requires authentication. If both access and refresh tokens have already expired (for example, after 24 hours of inactivity), {kib} initiates a new "handshake" and @@ -260,8 +256,7 @@ indicates that both access and refresh tokens are expired. Reloading the current [float] ===== Local and global logout -During logout, both the {kib} session cookie and access/refresh token pair are invalidated. Even if the cookie has been -leaked, it can't be re-used after logout. This is known as "local" logout. +During logout, both the {kib} session and {es} access/refresh token pair are invalidated. This is known as "local" logout. {kib} can also initiate a "global" logout or _Single Logout_ if it's supported by the external authentication provider and not explicitly disabled by {es}. In this case, the user is redirected to the external authentication provider for log out of diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 3599660645259..4ea71dde26857 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -56,7 +56,17 @@ xpack.security.encryptionKey: "something_at_least_32_characters" For more information, see <>. -- -. Optional: <>. +. Configure {kib}'s session expiration settings. We strongly recommend setting both idle timeout and lifespan settings: ++ +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.idleTimeout: "1h" +xpack.security.session.lifespan: "30d" +-------------------------------------------------------------------------------- + +For more information, see <>. +-- . Optional: <>. diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index 7c6f27ae3fd7e..93e956af44ebf 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -2,24 +2,24 @@ [[xpack-security-session-management]] === Session management -When you log in to {kib} it creates a session that is used to authenticate subsequent requests to {kib}. A session consists of two components: an encrypted cookie that is stored in your browser, and an encrypted document in a dedicated {es} hidden index. By default, the name of that index is `.kibana_security_session_1` where the prefix is derived from {kib}'s primary `.kibana` index. +When you log in, {kib} creates a session that is used to authenticate subsequent requests to {kib}. A session consists of two components: an encrypted cookie that is stored in your browser, and an encrypted document in a dedicated {es} hidden index. By default, the name of that index is `.kibana_security_session_1`, where the prefix is derived from the primary `.kibana` index. If either of these components are missing, the session is no longer valid. -When your session expires, or you log out of {kib} explicitly it will invalidate your cookie and remove session information from the index. {kib} also periodically invalidates and removes any expired sessions that weren't invalidated explicitly. +When your session expires, or you log out, {kib} will invalidate your cookie and remove session information from the index. {kib} also periodically invalidates and removes any expired sessions that weren't explicitly invalidated. [[session-idle-timeout]] ==== Session idle timeout -You can optionally expire sessions after a period of inactivity. By default, a session stays -active until the browser is closed. To define a sliding session expiration, set +You can expire sessions after a period of inactivity. This and `xpack.security.session.lifespan` are both +highly recommended. By default, session doesn't expire because of inactivity. To define a sliding session expiration, set the `xpack.security.session.idleTimeout` property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set -the idle timeout to expire idle sessions after 10 minutes of inactivity: +the idle timeout to expire sessions after 1 hour of inactivity: -- [source,yaml] -------------------------------------------------------------------------------- -xpack.security.session.idleTimeout: "10m" +xpack.security.session.idleTimeout: "1h" -------------------------------------------------------------------------------- -- @@ -27,25 +27,30 @@ xpack.security.session.idleTimeout: "10m" ==== Session lifespan You can configure the maximum session duration or "lifespan" -- also known as -the "absolute timeout". By default, a session stays active until the browser is -closed. If an idle timeout is defined, a session can still be extended +the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly +recommended. By default, session doesn't have a fixed lifespan, and if an idle timeout is defined, a session can still be extended indefinitely. To define a maximum session lifespan, set the `xpack.security.session.lifespan` property in the `kibana.yml` configuration file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set the lifespan to expire -sessions after 8 hours: +sessions after 30 days: -- [source,yaml] -------------------------------------------------------------------------------- -xpack.security.session.lifespan: "8h" +xpack.security.session.lifespan: "30d" -------------------------------------------------------------------------------- -- [[session-cleanup-interval]] ==== Session cleanup interval -You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70000ms', '50s', '3d', '1Y'). For example, schedule session index cleanup to be performed only once a day: +[IMPORTANT] +============================================================================ +If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out. This might lead to an infinitely growing session index. Configure the idle timeout and lifespan settings for the {kib} sessions so that they can be cleaned up even if you don't explicitly log out. +============================================================================ + +You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70000ms', '50s', '3d', '1Y'). For example, schedule the session index cleanup to perform once a day: -- [source,yaml] @@ -53,8 +58,3 @@ You can configure the interval at which {kib} tries to remove expired and invali xpack.security.session.cleanupInterval: "1d" -------------------------------------------------------------------------------- -- - -[IMPORTANT] -============================================================================ -If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out of {kib}. This may lead to an infinitely growing session index. It's one of the reasons why we strongly recommend configuring idle timeout and lifespan settings for the {kib} sessions so that they can be eventually cleaned up even if you don't log out of {kib} explicitly. -============================================================================ \ No newline at end of file From 5bc9502e669357ec8dd490c8014ffa79934bf996 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 17 Aug 2020 16:38:52 +0200 Subject: [PATCH 27/28] Review#8: more doc improvements. --- docs/settings/security-settings.asciidoc | 13 ++++++------- .../user/security/session-management.asciidoc | 19 ++++--------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 7b389fa0783be..795c934c645c2 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -96,16 +96,13 @@ The valid settings in the `xpack.security.authc.providers` namespace vary depend `..showInSelector` | Flag that indicates if the provider should have an entry on the Login Selector UI. Setting this to `false` doesn't remove the provider from the authentication chain. -|=== - +2+a| +[TIP] [NOTE] ============ You are unable to set this setting to `false` for `basic` and `token` authentication providers. ============ -[cols="2*<"] -|=== - | `xpack.security.authc.providers.` `..accessAgreement.message` | Access agreement text in Markdown format. For more information, refer to <>. @@ -167,6 +164,8 @@ There is a very limited set of cases when you'd want to change these settings. F [[login-ui-settings]] ===== Login user interface settings +You can configure the following settings in the `kibana.yml` file. + [cols="2*<"] |=== | `xpack.security.loginAssistanceMessage` @@ -216,7 +215,7 @@ highly recommended. By default, this setting is not set. 2+a| [TIP] ============ -The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). ============ | `xpack.security.session.lifespan` @@ -227,7 +226,7 @@ recommended. By default, this setting is not set. 2+a| [TIP] ============ -The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). ============ | `xpack.security.session.cleanupInterval` diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index 93e956af44ebf..0df5b3b31a203 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -9,12 +9,8 @@ When your session expires, or you log out, {kib} will invalidate your cookie and [[session-idle-timeout]] ==== Session idle timeout -You can expire sessions after a period of inactivity. This and `xpack.security.session.lifespan` are both -highly recommended. By default, session doesn't expire because of inactivity. To define a sliding session expiration, set -the `xpack.security.session.idleTimeout` property in the `kibana.yml` -configuration file. The idle timeout is formatted as a duration of -`[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set -the idle timeout to expire sessions after 1 hour of inactivity: +You can use `xpack.security.session.idleTimeout` to expire sessions after a period of inactivity. This and `xpack.security.session.lifespan` are both highly recommended. +By default, sessions don't expire because of inactivity. To define a sliding session expiration, set the property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 1 hour of inactivity: -- [source,yaml] @@ -26,14 +22,7 @@ xpack.security.session.idleTimeout: "1h" [[session-lifespan]] ==== Session lifespan -You can configure the maximum session duration or "lifespan" -- also known as -the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly -recommended. By default, session doesn't have a fixed lifespan, and if an idle timeout is defined, a session can still be extended -indefinitely. To define a maximum session lifespan, set the -`xpack.security.session.lifespan` property in the `kibana.yml` configuration -file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). For example, set the lifespan to expire -sessions after 30 days: +You can use `xpack.security.session.lifespan` to configure the maximum session duration or "lifespan" -- also known as the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly recommended. By default, sessions don't have a fixed lifespan, and if an idle timeout is defined, a session can still be extended indefinitely. To define a maximum session lifespan, set the property in the `kibana.yml` configuration file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the lifespan to expire sessions after 30 days: -- [source,yaml] @@ -50,7 +39,7 @@ xpack.security.session.lifespan: "30d" If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out. This might lead to an infinitely growing session index. Configure the idle timeout and lifespan settings for the {kib} sessions so that they can be cleaned up even if you don't explicitly log out. ============================================================================ -You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '70000ms', '50s', '3d', '1Y'). For example, schedule the session index cleanup to perform once a day: +You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, schedule the session index cleanup to perform once a day: -- [source,yaml] From 8ea6939a0ef0fa3268894cae54743184bd959872 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 17 Aug 2020 20:23:56 +0200 Subject: [PATCH 28/28] Review#8: more docs comments. --- docs/settings/security-settings.asciidoc | 8 ++++---- docs/user/security/authentication/index.asciidoc | 2 +- docs/user/security/securing-kibana.asciidoc | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 795c934c645c2..a0995cab984d4 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -123,7 +123,7 @@ In addition to <.useRelayStateDeepLink` -| Determines if the provider should treat the `RelayState` parameter as a deep link in {kib} during Identity Provider initiated log in. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL. For example, the `/app/dashboards#/list` link in `RelayState` parameter would look like this `RelayState=%2Fapp%2Fdashboards%23%2Flist`. +| Determines if the provider should treat the `RelayState` parameter as a deep link in {kib} during Identity Provider initiated log in. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL. For example, the `/app/dashboards#/list` link in `RelayState` parameter would look like this: `RelayState=%2Fapp%2Fdashboards%23%2Flist`. |=== @@ -169,7 +169,7 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== | `xpack.security.loginAssistanceMessage` -| Adds a message to the login UI. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. +| Adds a message to the login UI. Useful for displaying information about maintenance windows, links to corporate sign up pages, and so on. | `xpack.security.loginHelp` | Adds a message accessible at the login UI with additional help information for the login process. @@ -209,7 +209,7 @@ You can configure the following settings in the `kibana.yml` file. This is *not set* by default, which modern browsers will treat as `Lax`. If you use Kibana embedded in an iframe in modern browsers, you might need to set it to `None`. Setting this value to `None` requires cookies to be sent over a secure connection by setting `xpack.security.secureCookies: true`. | `xpack.security.session.idleTimeout` - | This setting ensures that user sessions will expire after a period of inactivity. This and `xpack.security.session.lifespan` are both + | Ensures that user sessions will expire after a period of inactivity. This and `xpack.security.session.lifespan` are both highly recommended. By default, this setting is not set. 2+a| @@ -219,7 +219,7 @@ The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', ============ | `xpack.security.session.lifespan` - | This setting ensures that user sessions will expire after the defined time period. This behavior also known as an "absolute timeout". If + | Ensures that user sessions will expire after the defined time period. This behavior also known as an "absolute timeout". If this is _not_ set, user sessions could stay active indefinitely. This and `xpack.security.session.idleTimeout` are both highly recommended. By default, this setting is not set. diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 369c881057fce..bd37351e9b60a 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -239,7 +239,7 @@ The following sections apply both to <> and <> ===== Access and refresh tokens Once the user logs in to {kib} Single Sign-On, either using SAML or OpenID Connect, {es} issues access and refresh tokens -that {kib} encrypts and stores them as a part of its own session. This way, the user isn't redirected to the Identity Provider +that {kib} encrypts and stores as a part of its own session. This way, the user isn't redirected to the Identity Provider for every request that requires authentication. It also means that the {kib} session depends on the <> settings, and the user is automatically logged out if the session expires. An access token that is stored in the session can expire, in which case {kib} will diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 4ea71dde26857..0f02279eaf1f3 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -56,7 +56,7 @@ xpack.security.encryptionKey: "something_at_least_32_characters" For more information, see <>. -- -. Configure {kib}'s session expiration settings. We strongly recommend setting both idle timeout and lifespan settings: +. Configure {kib}'s session expiration settings. Set both the idle timeout and lifespan settings: + -- [source,yaml]