diff --git a/x-pack/plugins/security/public/session/session_expired.test.ts b/x-pack/plugins/security/public/session/session_expired.test.ts index 2871d0bd24a46af..12aea64bf3f5aaf 100644 --- a/x-pack/plugins/security/public/session/session_expired.test.ts +++ b/x-pack/plugins/security/public/session/session_expired.test.ts @@ -7,6 +7,13 @@ import { coreMock } from 'src/core/public/mocks'; import { SessionExpired } from './session_expired'; +Object.defineProperty(window, 'sessionStorage', { + value: { + getItem: jest.fn(() => null), + }, + writable: true, +}); + const mockCurrentUrl = (url: string) => window.history.pushState({}, '', url); it('redirects user to "/logout" when there is no basePath', async () => { diff --git a/x-pack/plugins/security/public/session/session_expired.ts b/x-pack/plugins/security/public/session/session_expired.ts index da5d0715f8a0413..cfc8b8b35bffb2d 100644 --- a/x-pack/plugins/security/public/session/session_expired.ts +++ b/x-pack/plugins/security/public/session/session_expired.ts @@ -18,8 +18,10 @@ export class SessionExpired { `${window.location.pathname}${window.location.search}${window.location.hash}` ); const msg = isMaximum ? 'SESSION_ENDED' : 'SESSION_EXPIRED'; + const providerName = sessionStorage.getItem('session.provider'); + const provider = providerName ? `&provider=${providerName}` : ''; window.location.assign( - this.basePath.prepend(`/logout?next=${encodeURIComponent(next)}&msg=${msg}`) + this.basePath.prepend(`/logout?next=${encodeURIComponent(next)}&msg=${msg}${provider}`) ); } } diff --git a/x-pack/plugins/security/public/session/session_timeout.tsx b/x-pack/plugins/security/public/session/session_timeout.tsx index 44a8994b4ee8be2..7e56342760e9396 100644 --- a/x-pack/plugins/security/public/session/session_timeout.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.tsx @@ -59,7 +59,7 @@ export class SessionTimeout { // subscribe to a broadcast channel for session timeout messages // this allows us to synchronize the UX across tabs and avoid repetitive API calls - this.channel.onmessage = this.receiveMessage; + this.channel.onmessage = this.handleSessionInfo; this.elector.awaitLeadership().then(() => { this.updateTimeouts(); }); @@ -135,9 +135,10 @@ export class SessionTimeout { return { timeout, isMaximum }; }; - private receiveMessage = (sessionInfo: SessionInfo) => { + private handleSessionInfo = (sessionInfo: SessionInfo) => { this.sessionInfo = sessionInfo; this.updateTimeouts(); + sessionStorage.setItem('session.provider', sessionInfo.provider); }; private showWarning = () => { @@ -174,8 +175,7 @@ export class SessionTimeout { try { const result = await this.http.fetch(path, { method: 'GET', headers }); - this.sessionInfo = result; - this.updateTimeouts(); + this.handleSessionInfo(result); this.channel.postMessage(result); } catch (err) { // do nothing; 401 errors will be caught by the http interceptor diff --git a/x-pack/plugins/security/public/types.ts b/x-pack/plugins/security/public/types.ts index 10460c5fe6135d3..057adc03510866b 100644 --- a/x-pack/plugins/security/public/types.ts +++ b/x-pack/plugins/security/public/types.ts @@ -8,4 +8,5 @@ export interface SessionInfo { now: number; expires: number | null; maxExpires: number | null; + provider: string; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 72456ff815b0f4d..84db4f9fedc2feb 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -897,6 +897,32 @@ describe('Authenticator', () => { expect(deauthenticationResult.redirectURL).toBe('some-url'); }); + it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { + const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic' } }); + mockSessionStorage.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.logout.mockResolvedValue( + DeauthenticationResult.redirectTo('some-url') + ); + + const deauthenticationResult = await authenticator.logout(request); + + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(deauthenticationResult.redirected()).toBe(true); + expect(deauthenticationResult.redirectURL).toBe('some-url'); + }); + + it('returns `notHandled` if session does not exist and provider name is invalid', async () => { + const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); + mockSessionStorage.get.mockResolvedValue(null); + + const deauthenticationResult = await authenticator.logout(request); + + expect(deauthenticationResult.notHandled()).toBe(true); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + it('only clears session if it belongs to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index c1ff888c41234e3..7bf670485293321 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -330,10 +330,18 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const sessionValue = await this.getSessionValue(sessionStorage); + const providerName = this.getProviderName(request.query); if (sessionValue) { sessionStorage.clear(); return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); + } else if (providerName) { + // 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); + if (provider) { + return provider.logout(request, null); + } } // Normally when there is no active session in Kibana, `logout` method shouldn't do anything @@ -450,6 +458,13 @@ export class Authenticator { } } + private getProviderName(query: any): string | null { + if (query && query.provider && typeof query.provider === 'string') { + return query.provider; + } + return null; + } + private calculateExpiry( existingSession: ProviderSession | null ): { expires: number | null; maxExpires: number | null } { 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 8eb20447c7e2cde..b64b4d196aef1ba 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -422,20 +422,17 @@ describe('TokenAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented.', async () => { + it('returns `redirected` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; let deauthenticateResult = await provider.logout(request); - expect(deauthenticateResult.notHandled()).toBe(true); + expect(deauthenticateResult.redirected()).toBe(true); deauthenticateResult = await provider.logout(request, null); - expect(deauthenticateResult.notHandled()).toBe(true); + expect(deauthenticateResult.redirected()).toBe(true); sinon.assert.notCalled(mockOptions.tokens.invalidate); - - deauthenticateResult = await provider.logout(request, tokenPair); - expect(deauthenticateResult.notHandled()).toBe(false); }); it('fails if `tokens.invalidate` fails', async () => { diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index d1881ad4b5498b2..c5f8f07e50b11ca 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -120,18 +120,16 @@ 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) { + if (state) { + 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); + } + } else { 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); } const queryString = request.url.search || `?msg=LOGGED_OUT`; diff --git a/x-pack/plugins/security/server/routes/authentication.ts b/x-pack/plugins/security/server/routes/authentication.ts index 5cbb9de92944258..e46a50966c24dba 100644 --- a/x-pack/plugins/security/server/routes/authentication.ts +++ b/x-pack/plugins/security/server/routes/authentication.ts @@ -26,7 +26,7 @@ function defineCommonRoutes({ router, logger, authc, basePath }: RouteDefinition try { const sessionInfo = await authc.sessionInfo(request); // This is an authenticated request, so sessionInfo will always be non-null. - const { expires, maxExpires } = sessionInfo!; + const { expires, maxExpires, provider } = sessionInfo!; const now = new Date().getTime(); // 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. @@ -34,6 +34,7 @@ function defineCommonRoutes({ router, logger, authc, basePath }: RouteDefinition now, expires, maxExpires, + provider, }; return response.ok({ body }); } catch (err) {