From 3edd0bc8c3b8f33034c0800afd1d2b78f2bc29d0 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 28 Oct 2019 09:24:08 -0400 Subject: [PATCH] Add session lifespan, rename sessionTimeout to session.idleTimeout Extending sessions works properly. Still TODO: * Add sessionInfo endpoint to allow clients to fetch info * Modify UI to change the existing idle timeout notification, and add another one for exceeding the lifespan --- docs/settings/security-settings.asciidoc | 12 +- .../resources/bin/kibana-docker | 3 +- x-pack/legacy/plugins/security/index.js | 13 +- .../security/public/views/login/login.tsx | 3 + .../public/services/xpack_session_data.js | 19 ++ x-pack/plugins/security/public/plugin.ts | 2 +- .../authentication/authenticator.test.ts | 241 ++++++++++++++++-- .../server/authentication/authenticator.ts | 48 +++- .../security/server/authentication/index.ts | 2 +- x-pack/plugins/security/server/config.test.ts | 27 +- x-pack/plugins/security/server/config.ts | 20 +- x-pack/plugins/security/server/plugin.test.ts | 10 +- x-pack/plugins/security/server/plugin.ts | 15 +- 13 files changed, 362 insertions(+), 53 deletions(-) create mode 100644 x-pack/legacy/plugins/xpack_main/public/services/xpack_session_data.js diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 2ba1369369a66d5..b71772c6724ce83 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -47,7 +47,13 @@ is set to `true` if `server.ssl.certificate` and `server.ssl.key` are set. Set this to `true` if SSL is configured outside of {kib} (for example, you are routing requests through a load balancer or proxy). -`xpack.security.sessionTimeout`:: +`xpack.security.session.idleTimeout`:: Sets the session duration (in milliseconds). By default, sessions stay active -until the browser is closed. When this is set to an explicit timeout, closing the -browser still requires the user to log back in to {kib}. +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}. + +`xpack.security.session.lifespan`:: +Sets the maximum duration (in milliseconds), 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. 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 0926ef365c894c2..20000748394df3a 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 @@ -179,7 +179,8 @@ kibana_vars=( xpack.security.enabled xpack.security.encryptionKey xpack.security.secureCookies - xpack.security.sessionTimeout + xpack.security.session.timeout + xpack.security.session.lifespan telemetry.enabled ) diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index f9e82f575ce2e26..1569a70d56f3edc 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -45,7 +45,10 @@ export const security = (kibana) => new kibana.Plugin({ enabled: Joi.boolean().default(true), cookieName: Joi.any().description('This key is handled in the new platform security plugin ONLY'), encryptionKey: Joi.any().description('This key is handled in the new platform security plugin ONLY'), - sessionTimeout: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + session: Joi.object({ + idleTimeout: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + lifespan: Joi.any().description('This key is handled in the new platform security plugin ONLY'), + }).default(), secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'), authorization: Joi.object({ legacyFallback: Joi.object({ @@ -59,9 +62,10 @@ export const security = (kibana) => new kibana.Plugin({ }).default(); }, - deprecations: function ({ unused }) { + deprecations: function ({ rename, unused }) { return [ unused('authorization.legacyFallback.enabled'), + rename('sessionTimeout', 'session.idleTimeout'), ]; }, @@ -104,7 +108,10 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: securityPlugin.config.secureCookies, - sessionTimeout: securityPlugin.config.sessionTimeout, + session: { + idleTimeout: securityPlugin.config.session.idleTimeout, + lifespan: securityPlugin.config.session.lifespan, + }, enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), }; }, diff --git a/x-pack/legacy/plugins/security/public/views/login/login.tsx b/x-pack/legacy/plugins/security/public/views/login/login.tsx index 8b452e4c4fdf53f..0e44da05d7efe1b 100644 --- a/x-pack/legacy/plugins/security/public/views/login/login.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/login.tsx @@ -20,6 +20,9 @@ const messageMap = { SESSION_EXPIRED: i18n.translate('xpack.security.login.sessionExpiredDescription', { defaultMessage: 'Your session has timed out. Please log in again.', }), + SESSION_ENDED: i18n.translate('xpack.security.login.sessionEndedDescription', { + defaultMessage: 'Your session has exceeded the maximum time limit. Please log in again.', + }), LOGGED_OUT: i18n.translate('xpack.security.login.loggedOutDescription', { defaultMessage: 'You have logged out of Kibana.', }), diff --git a/x-pack/legacy/plugins/xpack_main/public/services/xpack_session_data.js b/x-pack/legacy/plugins/xpack_main/public/services/xpack_session_data.js new file mode 100644 index 000000000000000..9c9fbc0ae80444d --- /dev/null +++ b/x-pack/legacy/plugins/xpack_main/public/services/xpack_session_data.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +const XPACK_SESSION_DATA_KEY = 'xpackMain.sessionData'; + +export const xpackSessionData = { + get() { + return sessionStorage.getItem(XPACK_SESSION_DATA_KEY); + }, + set(updatedXPackSessionData) { + sessionStorage.setItem(XPACK_SESSION_DATA_KEY, updatedXPackSessionData); + }, + clear() { + sessionStorage.removeItem(XPACK_SESSION_DATA_KEY); + } +}; diff --git a/x-pack/plugins/security/public/plugin.ts b/x-pack/plugins/security/public/plugin.ts index 55d125bf993ec60..485edbd244bb551 100644 --- a/x-pack/plugins/security/public/plugin.ts +++ b/x-pack/plugins/security/public/plugin.ts @@ -23,7 +23,7 @@ export class SecurityPlugin implements Plugin = {}) { basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), isSystemAPIRequest: jest.fn(), - config: { sessionTimeout: null, authc: { providers: [], oidc: {}, saml: {} }, ...config }, + config: { + session: { idleTimeout: null, lifespan: null }, + authc: { providers: [], oidc: {}, saml: {} }, + ...config, + }, sessionStorageFactory: sessionStorageMock.createFactory(), }; } @@ -51,7 +55,9 @@ describe('Authenticator', () => { describe('initialization', () => { it('fails if authentication providers are not configured.', () => { - const mockOptions = getMockOptions({ authc: { providers: [], oidc: {}, saml: {} } }); + const mockOptions = getMockOptions({ + authc: { providers: [], oidc: {}, saml: {} }, + }); expect(() => new Authenticator(mockOptions)).toThrowError( 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' ); @@ -73,7 +79,9 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockOptions = getMockOptions({ + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -152,6 +160,7 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state: { authorization }, provider: 'basic', }); @@ -173,7 +182,12 @@ describe('Authenticator', () => { const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'token', + }); const authenticationResult = await authenticator.login(request, { provider: 'basic', @@ -286,7 +300,9 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockOptions = getMockOptions({ + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -345,6 +361,7 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state: { authorization }, provider: 'basic', }); @@ -367,6 +384,7 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state: { authorization }, provider: 'basic', }); @@ -381,7 +399,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); @@ -400,7 +423,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.succeeded()).toBe(true); @@ -409,26 +437,35 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state, provider: 'basic', }); expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('properly extends session timeout if it is defined.', async () => { + it('properly extends session expiration if it is defined.', async () => { const user = mockAuthenticatedUser(); const state = { authorization: 'Basic xxx' }; const request = httpServerMock.createKibanaRequest(); const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - // Create new authenticator with non-null `sessionTimeout`. + // Create new authenticator with non-null session `idleTimeout`. mockOptions = getMockOptions({ - sessionTimeout: 3600 * 24, + session: { + idleTimeout: 3600 * 24, + lifespan: null, + }, authc: { providers: ['basic'], oidc: {}, saml: {} }, }); mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); @@ -446,6 +483,103 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: currentDate + 3600 * 24, + maxExpires: null, + state, + provider: 'basic', + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('does not extend session expiration past the defined maximum session lifespan.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + 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: hr * 2, + lifespan: hr * 8, + }, + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue({ + // 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 + expires: currentDate + hr * 1, + maxExpires: currentDate + hr * 1.5, + state, + provider: 'basic', + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: currentDate + hr * 1.5, + maxExpires: currentDate + hr * 1.5, + state, + provider: 'basic', + }); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('sets session expiration if idleTimeout is null and lifespan is non-null.', async () => { + const user = mockAuthenticatedUser(); + const state = { authorization: 'Basic xxx' }; + const request = httpServerMock.createKibanaRequest(); + const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); + const hr = 1000 * 60 * 60; + + // Create new authenticator with null session `idleTimeout` and non-null `lifespan`. + mockOptions = getMockOptions({ + session: { + idleTimeout: null, + lifespan: hr * 8, + }, + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); + + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: currentDate + hr * 8, + state, + provider: 'basic', + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + + const authenticationResult = await authenticator.authenticate(request); + expect(authenticationResult.succeeded()).toBe(true); + expect(authenticationResult.user).toEqual(user); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expires: currentDate + hr * 8, + maxExpires: currentDate + hr * 8, state, provider: 'basic', }); @@ -460,7 +594,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(new Error('some error')) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.failed()).toBe(true); @@ -477,7 +616,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(new Error('some error')) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.failed()).toBe(true); @@ -498,6 +642,7 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue({ expires: null, + maxExpires: null, state: existingState, provider: 'basic', }); @@ -509,6 +654,7 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state: newState, provider: 'basic', }); @@ -527,6 +673,7 @@ describe('Authenticator', () => { ); mockSessionStorage.get.mockResolvedValue({ expires: null, + maxExpires: null, state: existingState, provider: 'basic', }); @@ -538,6 +685,7 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ expires: null, + maxExpires: null, state: newState, provider: 'basic', }); @@ -552,7 +700,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.failed()).toBe(true); @@ -569,7 +722,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.failed()).toBe(true); @@ -585,7 +743,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('some-url', { state: null }) ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.redirected()).toBe(true); @@ -602,7 +765,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.notHandled()).toBe(true); @@ -619,7 +787,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.notHandled()).toBe(true); @@ -636,7 +809,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'token', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.notHandled()).toBe(true); @@ -653,7 +831,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'token', + }); const authenticationResult = await authenticator.authenticate(request); expect(authenticationResult.notHandled()).toBe(true); @@ -668,7 +851,9 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } }); + mockOptions = getMockOptions({ + authc: { providers: ['basic'], oidc: {}, saml: {} }, + }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -697,7 +882,12 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') ); - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'basic', + }); const deauthenticationResult = await authenticator.logout(request); @@ -710,7 +900,12 @@ describe('Authenticator', () => { it('only clears session if it belongs to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + expires: null, + maxExpires: null, + state, + provider: 'token', + }); const deauthenticationResult = await authenticator.logout(request); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 18bdc9624b12b19..7e514c648ed5e31 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -47,6 +47,12 @@ export interface ProviderSession { */ expires: 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. + */ + maxExpires: 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. @@ -77,7 +83,7 @@ export interface ProviderLoginAttempt { } export interface AuthenticatorOptions { - config: Pick; + config: Pick; basePath: HttpServiceSetup['basePath']; loggers: LoggerFactory; clusterClient: IClusterClient; @@ -153,9 +159,14 @@ export class Authenticator { private readonly providers: Map; /** - * Session duration in ms. If `null` session will stay active until the browser is closed. + * Session timeout in ms. If `null` session will stay active until the browser is closed. + */ + private readonly idleTimeout: number | null = null; + + /** + * Session max lifespan in ms. If `null` session may live indefinitely. */ - private readonly ttl: number | null = null; + private readonly lifespan: number | null = null; /** * Internal authenticator logger. @@ -202,7 +213,9 @@ export class Authenticator { }) ); - this.ttl = this.options.config.sessionTimeout; + // only set these vars if they are defined in options (otherwise coalesce to existing/default) + this.idleTimeout = this.options.config.session.idleTimeout || this.idleTimeout; + this.lifespan = this.options.config.session.lifespan || this.lifespan; } /** @@ -257,10 +270,12 @@ export class Authenticator { if (existingSession && shouldClearSession) { sessionStorage.clear(); } else if (!attempt.stateless && authenticationResult.shouldUpdateState()) { + const { expires, maxExpires } = this.calculateExpiry(existingSession); sessionStorage.set({ state: authenticationResult.state, provider: attempt.provider, - expires: this.ttl && Date.now() + this.ttl, + expires, + maxExpires, }); } @@ -410,13 +425,34 @@ export class Authenticator { ) { sessionStorage.clear(); } else if (sessionCanBeUpdated) { + const { expires, maxExpires } = this.calculateExpiry(existingSession); sessionStorage.set({ state: authenticationResult.shouldUpdateState() ? authenticationResult.state : existingSession!.state, provider: providerType, - expires: this.ttl && Date.now() + this.ttl, + expires, + maxExpires, }); } } + + private calculateExpiry( + existingSession: ProviderSession | null + ): { expires: number | null; maxExpires: number | null } { + const maxExpires = existingSession + ? existingSession.maxExpires + : this.lifespan && Date.now() + this.lifespan; + let expires = this.idleTimeout && Date.now() + this.idleTimeout; + + if (expires && maxExpires && expires > maxExpires) { + // renewed expiration exceeds lifespan + expires = maxExpires; + } else if (!expires && maxExpires) { + // idleTimeout is not set, but lifespan is, so expiration should equal lifespan + expires = maxExpires; + } + + return { expires, maxExpires }; + } } diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 9553ddd09b2c13b..46dfeb611de73ae 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -70,7 +70,7 @@ export async function setupAuthentication({ const authenticator = new Authenticator({ clusterClient, basePath: core.http.basePath, - config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, + config: { session: config.session, authc: config.authc }, isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), loggers, sessionStorageFactory: await core.http.createCookieSessionStorageFactory({ diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 943d582bf484a7b..a84e4ca6a521c23 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -22,7 +22,10 @@ describe('config schema', () => { "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "secureCookies": false, - "sessionTimeout": null, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, } `); @@ -36,7 +39,10 @@ describe('config schema', () => { "cookieName": "sid", "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "secureCookies": false, - "sessionTimeout": null, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, } `); @@ -49,7 +55,10 @@ describe('config schema', () => { }, "cookieName": "sid", "secureCookies": false, - "sessionTimeout": null, + "session": Object { + "idleTimeout": null, + "lifespan": null, + }, } `); }); @@ -250,7 +259,11 @@ describe('createConfig$()', () => { const config = await createConfig$(contextMock, true) .pipe(first()) .toPromise(); - expect(config).toEqual({ encryptionKey: 'ab'.repeat(16), secureCookies: true }); + expect(config).toEqual({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + session: { idleTimeout: null, lifespan: null }, + }); expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ @@ -270,7 +283,7 @@ describe('createConfig$()', () => { const config = await createConfig$(contextMock, false) .pipe(first()) .toPromise(); - expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: false }); + expect(config.secureCookies).toEqual(false); expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ @@ -290,7 +303,7 @@ describe('createConfig$()', () => { const config = await createConfig$(contextMock, false) .pipe(first()) .toPromise(); - expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + expect(config.secureCookies).toEqual(true); expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` Array [ @@ -310,7 +323,7 @@ describe('createConfig$()', () => { const config = await createConfig$(contextMock, true) .pipe(first()) .toPromise(); - expect(config).toEqual({ encryptionKey: 'a'.repeat(32), secureCookies: true }); + expect(config.secureCookies).toEqual(true); expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 6fe3fc73e458c30..ace6f57910fe14f 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -33,7 +33,11 @@ export const ConfigSchema = schema.object( schema.maybe(schema.string({ minLength: 32 })), schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) ), - sessionTimeout: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }), + sessionTimeout: schema.maybe(schema.oneOf([schema.number(), schema.literal(null)])), // DEPRECATED + session: schema.object({ + idleTimeout: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }), + lifespan: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }), + }), secureCookies: schema.boolean({ defaultValue: false }), authc: schema.object({ providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), @@ -82,11 +86,23 @@ export function createConfig$(context: PluginInitializerContext, isTLSEnabled: b secureCookies = true; } - return { + // "sessionTimeout" is deprecated and replaced with "session.idleTimeout" + // however, NP does not yet have a mechanism to automatically rename deprecated keys + // for the time being, we'll do it manually: + const sess = config.session; + const session = { + idleTimeout: (sess && sess.idleTimeout) || config.sessionTimeout || null, + lifespan: (sess && sess.lifespan) || null, + }; + + const val = { ...config, encryptionKey, secureCookies, + session, }; + delete val.sessionTimeout; // DEPRECATED + return val; }) ); } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 7fa8f20476f90e0..450adadc07080ac 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -18,7 +18,10 @@ describe('Security Plugin', () => { plugin = new Plugin( coreMock.createPluginInitializerContext({ cookieName: 'sid', - sessionTimeout: 1500, + session: { + idleTimeout: 1500, + lifespan: null, + }, authc: { providers: ['saml', 'token'], saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, @@ -56,7 +59,10 @@ describe('Security Plugin', () => { }, "cookieName": "sid", "secureCookies": true, - "sessionTimeout": 1500, + "session": Object { + "idleTimeout": 1500, + "lifespan": null, + }, }, "registerLegacyAPI": [Function], } diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 18717f3e132b9f5..513dbf5fb3ced4a 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -36,9 +36,13 @@ export interface PluginSetupContract { authc: Authentication; config: RecursiveReadonly<{ - sessionTimeout: number | null; + session: { + idleTimeout: number | null; + lifespan: number | null; + }; secureCookies: boolean; - authc: { providers: string[] }; + // eslint-disable-next-line @typescript-eslint/array-type + authc: { providers: Array }; }>; registerLegacyAPI: (legacyAPI: LegacyAPI) => void; @@ -94,9 +98,12 @@ export class Plugin { authc, // We should stop exposing this config as soon as only new platform plugin consumes it. The only - // exception may be `sessionTimeout` as other parts of the app may want to know it. + // exception may be `session` as other parts of the app may want to know it. config: { - sessionTimeout: config.sessionTimeout, + session: { + idleTimeout: config.session.idleTimeout, + lifespan: config.session.lifespan, + }, secureCookies: config.secureCookies, cookieName: config.cookieName, authc: { providers: config.authc.providers },