From 7827fa5976b8c26b8fb388a6ed1be2edc82070f3 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 15 May 2020 10:00:59 +0200 Subject: [PATCH] [7.7] Allow IdP initiated SAML login with session containing expired token. (#64339) --- .../authentication/providers/saml.test.ts | 347 +++++++++--------- .../server/authentication/providers/saml.ts | 12 +- .../server/authentication/tokens.test.ts | 151 ++++---- .../security/server/authentication/tokens.ts | 20 +- .../apis/security/saml_login.ts | 165 +++++---- 5 files changed, 387 insertions(+), 308 deletions(-) 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 c85a44d819ad..9c21e0b167f9 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -396,193 +396,208 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('redirects to the home page if new SAML Response is for the same user.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - username: 'user', - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - realm: 'test-realm', - }; - - const user = { username: 'user', authentication_realm: { name: 'saml1' } }; - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + for (const [description, response] of [ + [ + 'session is valid', + Promise.resolve({ username: 'user', authentication_realm: { name: 'saml1' } }), + ], + [ + 'session is is expired', + Promise.reject(ElasticsearchErrorHelpers.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 () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const state = { + username: 'user', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - realm: 'test-realm', - }); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.invalidate.mockResolvedValue(undefined); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + realm: 'test-realm', + }); + + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + await expect( + provider.login( + request, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + state + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo('/base-path/', { + state: { + username: 'user', + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', + }, + }) + ); - await expect( - provider.login( - request, - { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, - state - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo('/base-path/', { - state: { - username: 'user', - accessToken: 'new-valid-token', - refreshToken: 'new-valid-refresh-token', - realm: 'test-realm', - }, - }) - ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml' }, - } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { + body: { ids: [], content: 'saml-response-xml' }, + } + ); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + 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.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - username: 'user', - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - realm: 'test-realm', - }; - - const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; - const newUser = { username: 'new-user', authentication_realm: { name: 'saml1' } }; - mockOptions.client.asScoped.mockImplementation(scopeableRequest => { - if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(existingUser); - return mockScopedClusterClient; - } + 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 newUser = { username: 'new-user', authentication_realm: { name: 'saml1' } }; + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); + return mockScopedClusterClient; + } - if (scopeableRequest?.headers.authorization === 'Bearer new-valid-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(newUser); - return mockScopedClusterClient; - } + if (scopeableRequest?.headers.authorization === 'Bearer new-valid-token') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(newUser); + return mockScopedClusterClient; + } - throw new Error('Unexpected call'); - }); + throw new Error('Unexpected call'); + }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'new-user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - realm: 'test-realm', - }); - - mockOptions.tokens.invalidate.mockResolvedValue(undefined); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'new-user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + realm: 'test-realm', + }); + + 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', + }, + }) + ); - 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.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml' }, - } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { + body: { ids: [], content: 'saml-response-xml' }, + } + ); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + 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 another realm.', async () => { - const request = httpServerMock.createKibanaRequest(); - const state = { - username: 'user', - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - realm: 'saml1', - }; - - const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; - const newUser = { username: 'user', authentication_realm: { name: 'saml2' } }; - mockOptions.client.asScoped.mockImplementation(scopeableRequest => { - if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(existingUser); - return mockScopedClusterClient; - } - - if (scopeableRequest?.headers.authorization === 'Bearer new-valid-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(newUser); - return mockScopedClusterClient; - } - - throw new Error('Unexpected call'); - }); + it(`redirects to \`overwritten_session\` if new SAML Response is for another realm if ${description}.`, async () => { + const request = httpServerMock.createKibanaRequest(); + const state = { + username: 'user', + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + realm: 'saml1', + }; + + const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; + const newUser = { username: 'user', authentication_realm: { name: 'saml2' } }; + mockOptions.client.asScoped.mockImplementation(scopeableRequest => { + if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(existingUser); + return mockScopedClusterClient; + } - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - realm: 'saml2', - }); + if (scopeableRequest?.headers.authorization === 'Bearer new-valid-token') { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(newUser); + return mockScopedClusterClient; + } - mockOptions.tokens.invalidate.mockResolvedValue(undefined); + throw new Error('Unexpected call'); + }); - 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: 'user', - accessToken: 'new-valid-token', - refreshToken: 'new-valid-refresh-token', - realm: 'saml2', - }, - }) - ); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + access_token: 'new-valid-token', + refresh_token: 'new-valid-refresh-token', + realm: 'saml2', + }); + + 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: 'user', + accessToken: 'new-valid-token', + refreshToken: 'new-valid-refresh-token', + realm: 'saml2', + }, + }) + ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml' }, - } - ); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + 'shield.samlAuthenticate', + { + body: { ids: [], content: 'saml-response-xml' }, + } + ); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }); }); - }); + } }); describe('User initiated login with captured redirect URL', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index aabe711e4876..8f5f1e9f9241 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -156,10 +156,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return await this.loginWithSAMLResponse(request, samlResponse, state); } - if (authenticationResult.succeeded()) { - // If user has been authenticated via session, but request also includes SAML payload - // we should check whether this payload is for the exactly same user and if not - // we'll re-authenticate user and forward to a page with the respective warning. + // If user has been authenticated via session or failed to do so because of expired access token, + // but request also includes SAML payload we should check whether this payload is for the exactly + // same user and if not we'll re-authenticate user and forward to a page with the respective warning. + if ( + authenticationResult.succeeded() || + (authenticationResult.failed() && + Tokens.isAccessTokenExpiredError(authenticationResult.error)) + ) { return await this.loginWithNewSAMLResponse( request, samlResponse, diff --git a/x-pack/plugins/security/server/authentication/tokens.test.ts b/x-pack/plugins/security/server/authentication/tokens.test.ts index 82f29310c04c..57366183050d 100644 --- a/x-pack/plugins/security/server/authentication/tokens.test.ts +++ b/x-pack/plugins/security/server/authentication/tokens.test.ts @@ -25,7 +25,7 @@ describe('Tokens', () => { tokens = new Tokens(tokensOptions); }); - it('isAccessTokenExpiredError() returns `true` only if token expired or its document is missing', () => { + it('isAccessTokenExpiredError() returns `true` only if token expired', () => { const nonExpirationErrors = [ {}, new Error(), @@ -91,55 +91,66 @@ describe('Tokens', () => { }); describe('invalidate()', () => { - it('throws if call to delete access token responds with an error', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - const failureReason = new Error('failed to delete token'); - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { - if (args && args.body && args.body.token) { - return Promise.reject(failureReason); - } - - return Promise.resolve({ invalidated_tokens: 1 }); + for (const [description, failureReason] of [ + ['an unknown error', new Error('failed to delete token')], + ['a 404 error without body', { statusCode: 404 }], + ] as Array<[string, object]>) { + it(`throws if call to delete access token responds with ${description}`, async () => { + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.token) { + return Promise.reject(failureReason); + } + + return Promise.resolve({ invalidated_tokens: 1 }); + }); + + await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { token: tokenPair.accessToken }, + } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { refresh_token: tokenPair.refreshToken }, + } + ); }); - await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); - }); - - it('throws if call to delete refresh token responds with an error', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - const failureReason = new Error('failed to delete token'); - mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { - if (args && args.body && args.body.refresh_token) { - return Promise.reject(failureReason); - } - - return Promise.resolve({ invalidated_tokens: 1 }); + it(`throws if call to delete refresh token responds with ${description}`, async () => { + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => { + if (args && args.body && args.body.refresh_token) { + return Promise.reject(failureReason); + } + + return Promise.resolve({ invalidated_tokens: 1 }); + }); + + await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { token: tokenPair.accessToken }, + } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { refresh_token: tokenPair.refreshToken }, + } + ); }); - - await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason); - - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); - }); + } it('invalidates all provided tokens', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; @@ -187,23 +198,35 @@ describe('Tokens', () => { ); }); - it('does not fail if none of the tokens were invalidated', async () => { - const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; - - mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 0 }); - - await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); - - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { token: tokenPair.accessToken } } - ); - expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( - 'shield.deleteAccessToken', - { body: { refresh_token: tokenPair.refreshToken } } - ); - }); + for (const [description, response] of [ + ['none of the tokens were invalidated', Promise.resolve({ invalidated_tokens: 0 })], + [ + '404 error is returned', + Promise.reject({ statusCode: 404, body: { invalidated_tokens: 0 } }), + ], + ] as Array<[string, Promise]>) { + it(`does not fail if ${description}`, async () => { + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + + mockClusterClient.callAsInternalUser.mockImplementation(() => response); + + await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { token: tokenPair.accessToken }, + } + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( + 'shield.deleteAccessToken', + { + body: { refresh_token: tokenPair.refreshToken }, + } + ); + }); + } it('does not fail if more than one token per access or refresh token were invalidated', async () => { const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; diff --git a/x-pack/plugins/security/server/authentication/tokens.ts b/x-pack/plugins/security/server/authentication/tokens.ts index ea7b5d5a9ff3..9117c9a679a4 100644 --- a/x-pack/plugins/security/server/authentication/tokens.ts +++ b/x-pack/plugins/security/server/authentication/tokens.ts @@ -103,8 +103,15 @@ export class Tokens { ).invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate refresh token: ${err.message}`); - // We don't re-throw the error here to have a chance to invalidate access token if it's provided. - invalidationError = err; + + // When using already deleted refresh token, Elasticsearch responds with 404 and a body that + // shows that no tokens were invalidated. + if (getErrorStatusCode(err) === 404 && err.body?.invalidated_tokens === 0) { + invalidatedTokensCount = err.body.invalidated_tokens; + } else { + // We don't re-throw the error here to have a chance to invalidate access token if it's provided. + invalidationError = err; + } } if (invalidatedTokensCount === 0) { @@ -128,7 +135,14 @@ export class Tokens { ).invalidated_tokens; } catch (err) { this.logger.debug(`Failed to invalidate access token: ${err.message}`); - invalidationError = err; + + // When using already deleted access token, Elasticsearch responds with 404 and a body that + // shows that no tokens were invalidated. + if (getErrorStatusCode(err) === 404 && err.body?.invalidated_tokens === 0) { + invalidatedTokensCount = err.body.invalidated_tokens; + } else { + invalidationError = err; + } } if (invalidatedTokensCount === 0) { 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 51706a430b65..eede139839fc 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 @@ -35,7 +35,7 @@ export default function({ getService }: FtrProviderContext) { }); } - async function checkSessionCookie(sessionCookie: Cookie) { + async function checkSessionCookie(sessionCookie: Cookie, username = 'a@b.c') { expect(sessionCookie.key).to.be('sid'); expect(sessionCookie.value).to.not.be.empty(); expect(sessionCookie.path).to.be('/'); @@ -59,7 +59,7 @@ export default function({ getService }: FtrProviderContext) { 'authentication_provider', ]); - expect(apiResponse.body.username).to.be('a@b.c'); + expect(apiResponse.body.username).to.be(username); } describe('SAML authentication', () => { @@ -692,6 +692,29 @@ export default function({ getService }: FtrProviderContext) { const existingUsername = 'a@b.c'; let existingSessionCookie: Cookie; + const testScenarios: Array<[string, () => Promise]> = [ + // Default scenario when active cookie has an active access token. + ['when access token is valid', async () => {}], + // Scenario when active cookie has an expired access token. Access token expiration is set + // to 15s for API integration tests so we need to wait for 20s to make sure token expires. + ['when access token is expired', async () => await delay(20000)], + // Scenario when active cookie references to access/refresh token pair that were already + // removed from Elasticsearch (to simulate 24h when expired tokens are removed). + [ + 'when access token document is missing', + async () => { + const esResponse = await getService('legacyEs').deleteByQuery({ + index: '.security-tokens', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse) + .to.have.property('deleted') + .greaterThan(0); + }, + ], + ]; + beforeEach(async () => { const captureURLResponse = await supertest .get('/abc/xyz/handshake?one=two three') @@ -725,76 +748,76 @@ export default function({ getService }: FtrProviderContext) { )!; }); - it('should renew session and redirect to the home page if login is for the same user', async () => { - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', existingSessionCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ username: existingUsername }) }) - .expect('location', '/') - .expect(302); - - const newSessionCookie = request.cookie( - samlAuthenticationResponse.headers['set-cookie'][0] - )!; - 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 - .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.' - ); - - // Only tokens from new session are valid. - const acceptedResponse = await supertest - .get('/internal/security/me') - .set('kbn-xsrf', 'xxx') - .set('Cookie', newSessionCookie.cookieString()) - .expect(200); - expect(acceptedResponse.body).to.have.property('username', existingUsername); - }); - - it('should create a new session and redirect to the `overwritten_session` if login is for another user', async () => { - const newUsername = 'c@d.e'; - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', existingSessionCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ username: newUsername }) }) - .expect('location', '/security/overwritten_session') - .expect(302); - - const newSessionCookie = request.cookie( - samlAuthenticationResponse.headers['set-cookie'][0] - )!; - 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 - .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.' - ); + for (const [description, setup] of testScenarios) { + it(`should renew session and redirect to the home page if login is for the same user ${description}`, async () => { + await setup(); + + const samlAuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .set('kbn-xsrf', 'xxx') + .set('Cookie', existingSessionCookie.cookieString()) + .send({ SAMLResponse: await createSAMLResponse({ username: existingUsername }) }) + .expect(302); + + expect(samlAuthenticationResponse.headers.location).to.be('/'); + + const newSessionCookie = request.cookie( + samlAuthenticationResponse.headers['set-cookie'][0] + )!; + 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 + .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.' + ); + + // Only tokens from new session are valid. + await checkSessionCookie(newSessionCookie); + }); - // Only tokens from new session are valid. - const acceptedResponse = await supertest - .get('/internal/security/me') - .set('kbn-xsrf', 'xxx') - .set('Cookie', newSessionCookie.cookieString()) - .expect(200); - expect(acceptedResponse.body).to.have.property('username', newUsername); - }); + it(`should create a new session and redirect to the \`overwritten_session\` if login is for another user ${description}`, async () => { + await setup(); + + const newUsername = 'c@d.e'; + const samlAuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .set('kbn-xsrf', 'xxx') + .set('Cookie', existingSessionCookie.cookieString()) + .send({ SAMLResponse: await createSAMLResponse({ username: newUsername }) }) + .expect(302); + + expect(samlAuthenticationResponse.headers.location).to.be( + '/security/overwritten_session' + ); + + const newSessionCookie = request.cookie( + samlAuthenticationResponse.headers['set-cookie'][0] + )!; + 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 + .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.' + ); + + // Only tokens from new session are valid. + await checkSessionCookie(newSessionCookie, newUsername); + }); + } }); describe('handshake with very long URL path or fragment', () => {