diff --git a/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js b/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js index cf391b4bfdfc0..58cffca347e35 100644 --- a/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js @@ -372,7 +372,7 @@ describe('Authenticator', () => { session.clear.resolves(); cluster.callWithRequest - .withArgs(request).rejects({ body: { error: { reason: 'token expired' } } }); + .withArgs(request).rejects({ statusCode: 401 }); cluster.callWithInternalUser.withArgs('shield.getAccessToken').rejects( Boom.badRequest('refresh token expired') diff --git a/x-pack/plugins/security/server/lib/authentication/providers/__tests__/token.js b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/token.js index b13fc1ce8934f..6c99a78847b6d 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/__tests__/token.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/token.js @@ -137,7 +137,7 @@ describe('TokenAuthenticationProvider', () => { callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } }) @@ -176,26 +176,6 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.notHandled()).to.be(true); }); - it('fails if state contains invalid credentials.', async () => { - const request = requestFixture(); - const accessToken = 'foo'; - const authorization = `Bearer ${accessToken}`; - - const authenticationError = new Error('Forbidden'); - callWithRequest - .withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate') - .returns(Promise.reject(authenticationError)); - - const authenticationResult = await provider.authenticate(request, { accessToken }); - - expect(request.headers).to.not.have.property('authorization'); - expect(authenticationResult.failed()).to.be(true); - expect(authenticationResult.user).to.be.eql(undefined); - expect(authenticationResult.state).to.be.eql(undefined); - expect(authenticationResult.error).to.be.eql(authenticationError); - sinon.assert.calledOnce(callWithRequest); - }); - it('authenticates only via `authorization` header even if state is available.', async () => { const accessToken = 'foo'; const authorization = `Bearer ${accessToken}`; @@ -263,14 +243,14 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.error).to.be.eql(authenticationError); }); - it('fails when header contains a rejected token', async () => { + it('fails if authentication with token from header fails with unknown error', async () => { const authorization = `Bearer foo`; const request = requestFixture({ headers: { authorization } }); - const authenticationError = new Error('Forbidden'); + const authenticationError = new errors.InternalServerError('something went wrong'); callWithRequest .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(authenticationError)); + .rejects(authenticationError); const authenticationResult = await provider.authenticate(request); @@ -282,14 +262,14 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.error).to.be.eql(authenticationError); }); - it('fails when session contains a rejected token', async () => { + it('fails if authentication with token from state fails with unknown error.', async () => { const accessToken = 'foo'; const request = requestFixture(); - const authenticationError = new Error('Forbidden'); + const authenticationError = new errors.InternalServerError('something went wrong'); callWithRequest - .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(authenticationError)); + .withArgs(sinon.match({ headers: { authorization: `Bearer ${accessToken}` } }), 'shield.authenticate') + .rejects(authenticationError); const authenticationResult = await provider.authenticate(request, { accessToken }); @@ -302,17 +282,17 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.error).to.be.eql(authenticationError); }); - it('fails if token refresh is rejected', async () => { + it('fails if token refresh is rejected with unknown error', async () => { const request = requestFixture(); callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); + .rejects({ statusCode: 401 }); - const authenticationError = new Error('failed to refresh token'); + const refreshError = new errors.InternalServerError('failed to refresh token'); callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } }) - .returns(Promise.reject(authenticationError)); + .rejects(refreshError); const accessToken = 'foo'; const refreshToken = 'bar'; @@ -325,7 +305,36 @@ describe('TokenAuthenticationProvider', () => { expect(authenticationResult.failed()).to.be(true); expect(authenticationResult.user).to.be.eql(undefined); expect(authenticationResult.state).to.be.eql(undefined); - expect(authenticationResult.error).to.be.eql(authenticationError); + expect(authenticationResult.error).to.be.eql(refreshError); + }); + + it('redirects non-AJAX requests to /login and clears session if token document is missing', async () => { + const request = requestFixture({ path: '/some-path' }); + + callWithRequest + .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') + .rejects({ + statusCode: 500, + body: { error: { reason: 'token document is missing and must be present' } }, + }); + + callWithInternalUser + .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } }) + .rejects(new errors.BadRequest('failed to refresh token')); + + const accessToken = 'foo'; + const refreshToken = 'bar'; + const authenticationResult = await provider.authenticate(request, { accessToken, refreshToken }); + + sinon.assert.calledOnce(callWithRequest); + sinon.assert.calledOnce(callWithInternalUser); + + expect(request.headers).to.not.have.property('authorization'); + expect(authenticationResult.redirected()).to.be(true); + expect(authenticationResult.redirectURL).to.be('/base-path/login?next=%2Fsome-path'); + expect(authenticationResult.user).to.be.eql(undefined); + expect(authenticationResult.state).to.be.eql(null); + expect(authenticationResult.error).to.be.eql(undefined); }); it('redirects non-AJAX requests to /login and clears session if token refresh fails with 400 error', async () => { @@ -333,7 +342,7 @@ describe('TokenAuthenticationProvider', () => { callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } }) @@ -359,7 +368,7 @@ describe('TokenAuthenticationProvider', () => { callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); const authenticationError = new errors.BadRequest('failed to refresh token'); callWithInternalUser @@ -385,16 +394,16 @@ describe('TokenAuthenticationProvider', () => { callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer foo' } }), 'shield.authenticate') - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'bar' } }) .returns(Promise.resolve({ access_token: 'newfoo', refresh_token: 'newbar' })); - const authenticationError = new Error('Some error'); + const authenticationError = new errors.AuthenticationException('Some error'); callWithRequest .withArgs(sinon.match({ headers: { authorization: 'Bearer newfoo' } }), 'shield.authenticate') - .returns(Promise.reject(authenticationError)); + .rejects(authenticationError); const accessToken = 'foo'; const refreshToken = 'bar'; diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts index 5498ab0854477..e5df0d09a7080 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.test.ts @@ -212,7 +212,7 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is rejected because of unknown reason.', async () => { const request = requestFixture(); - const failureReason = new Error('Token is not valid!'); + const failureReason = { statusCode: 500, message: 'Token is not valid!' }; callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); const authenticationResult = await provider.authenticate(request, { @@ -235,7 +235,7 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); callWithRequest .withArgs( @@ -264,7 +264,7 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('fails if token from the state is expired and refresh attempt failed too.', async () => { + it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { const request = requestFixture(); callWithRequest @@ -272,9 +272,12 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); - const refreshFailureReason = new Error('Something is wrong with refresh token.'); + const refreshFailureReason = { + statusCode: 500, + message: 'Something is wrong with refresh token.', + }; callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token' }, @@ -291,7 +294,7 @@ describe('SAMLAuthenticationProvider', () => { expect(authenticationResult.error).toBe(refreshFailureReason); }); - it('fails for AJAX requests with user friendly message if refresh token is used more than once.', async () => { + it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); callWithRequest @@ -299,17 +302,17 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token' }, + body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, }) - .rejects({ body: { error_description: 'token has already been refreshed' } }); + .rejects({ statusCode: 400 }); const authenticationResult = await provider.authenticate(request, { accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', + refreshToken: 'expired-refresh-token', }); expect(request.headers).not.toHaveProperty('authorization'); @@ -319,7 +322,7 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('initiates SAML handshake for non-AJAX requests if refresh token is used more than once.', async () => { + it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => { const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); callWithInternalUser.withArgs('shield.samlPrepare').resolves({ @@ -332,17 +335,20 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ + statusCode: 500, + body: { error: { reason: 'token document is missing and must be present' } }, + }); callWithInternalUser .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: 'invalid-refresh-token' }, + body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, }) - .rejects({ body: { error_description: 'token has already been refreshed' } }); + .rejects({ statusCode: 400 }); const authenticationResult = await provider.authenticate(request, { accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', + refreshToken: 'expired-refresh-token', }); sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', { @@ -359,34 +365,6 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { - const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }); - - callWithRequest - .withArgs( - sinon.match({ headers: { authorization: 'Bearer expired-token' } }), - 'shield.authenticate' - ) - .rejects({ body: { error: { reason: 'token expired' } } }); - - callWithInternalUser - .withArgs('shield.getAccessToken', { - body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, - }) - .rejects({ body: { error_description: 'refresh token is expired' } }); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token', - }); - - expect(request.headers).not.toHaveProperty('authorization'); - expect(authenticationResult.failed()).toBe(true); - expect(authenticationResult.error).toEqual( - Boom.badRequest('Both access and refresh tokens are expired.') - ); - }); - it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => { const request = requestFixture({ path: '/some-path', basePath: '/s/foo' }); @@ -400,13 +378,13 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .rejects({ body: { error: { reason: 'token expired' } } }); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs('shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' }, }) - .rejects({ body: { error_description: 'refresh token is expired' } }); + .rejects({ statusCode: 400 }); const authenticationResult = await provider.authenticate(request, { accessToken: 'expired-token', @@ -444,7 +422,7 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from `authorization` header is rejected.', async () => { const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); - const failureReason = new Error('Token is not valid!'); + const failureReason = { statusCode: 401 }; callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); const authenticationResult = await provider.authenticate(request); @@ -457,7 +435,7 @@ describe('SAMLAuthenticationProvider', () => { const user = { username: 'user' }; const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } }); - const failureReason = new Error('Token is not valid!'); + const failureReason = { statusCode: 401 }; callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); callWithRequest diff --git a/x-pack/plugins/security/server/lib/authentication/providers/saml.ts b/x-pack/plugins/security/server/lib/authentication/providers/saml.ts index 8f7773e70193d..55a7fe747069f 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.ts @@ -8,6 +8,7 @@ import Boom from 'boom'; import { Request } from 'hapi'; import { Cluster } from 'src/legacy/core_plugins/elasticsearch'; import { canRedirectRequest } from '../../can_redirect_request'; +import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -50,16 +51,6 @@ interface ProviderState { refreshToken?: string; } -/** - * Represents error returned from Elasticsearch API. - */ -interface APIError extends Error { - body?: { - error?: { reason?: string }; - error_description?: string; - }; -} - /** * Defines the shape of the request body containing SAML response. */ @@ -76,25 +67,21 @@ interface SAMLRequestQuery { } /** - * Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request - * has been rejected because of expired token, otherwise returns `false`. - * @param err Error returned from Elasticsearch. - */ -function isAccessTokenExpiredError(err?: APIError) { - return err && err.body && err.body.error && err.body.error.reason === 'token expired'; -} - -/** - * Checks the error returned by Elasticsearch as the result of `getAccessToken` call and returns `true` if - * request has been rejected because of invalid refresh token (expired after 24 hours or have been used already), - * otherwise returns `false`. + * If request with access token fails with `401 Unauthorized` then this token is no + * longer valid and we should try to refresh it. Another use case that we should + * temporarily support (until elastic/elasticsearch#38866 is fixed) is when token + * document has been removed and ES responds with `500 Internal Server Error`. * @param err Error returned from Elasticsearch. */ -function isInvalidRefreshTokenError(err: APIError) { +function isAccessTokenExpiredError(err?: any) { + const errorStatusCode = getErrorStatusCode(err); return ( - err.body && - (err.body.error_description === 'token has already been refreshed' || - err.body.error_description === 'refresh token is expired') + errorStatusCode === 401 || + (errorStatusCode === 500 && + err && + err.body && + err.body.error && + err.body.error.reason === 'token document is missing and must be present') ); } @@ -476,7 +463,7 @@ export class SAMLAuthenticationProvider { // handshake. Obviously we can't do that for AJAX requests, so we just reply with `400` and clear error message. // There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. - if (isInvalidRefreshTokenError(err)) { + if (getErrorStatusCode(err) === 400) { if (canRedirectRequest(request)) { this.options.log( ['debug', 'security', 'saml'], diff --git a/x-pack/plugins/security/server/lib/authentication/providers/token.js b/x-pack/plugins/security/server/lib/authentication/providers/token.js index 96d3c304c3445..74b3778a816ad 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/token.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/token.js @@ -27,15 +27,23 @@ import { DeauthenticationResult } from '../deauthentication_result'; */ /** - * Checks the error returned by Elasticsearch as the result of `authenticate` call and returns `true` if request - * has been rejected because of expired token, otherwise returns `false`. + * If request with access token fails with `401 Unauthorized` then this token is no + * longer valid and we should try to refresh it. Another use case that we should + * temporarily support (until elastic/elasticsearch#38866 is fixed) is when token + * document has been removed and ES responds with `500 Internal Server Error`. * @param {Object} err Error returned from Elasticsearch. * @returns {boolean} */ function isAccessTokenExpiredError(err) { - return err.body - && err.body.error - && err.body.error.reason === 'token expired'; + const errorStatusCode = getErrorStatusCode(err); + return ( + errorStatusCode === 401 || + (errorStatusCode === 500 && + err && + err.body && + err.body.error && + err.body.error.reason === 'token document is missing and must be present') + ); } /** diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.js b/x-pack/test/saml_api_integration/apis/security/saml_login.js index a664d68d1b2d1..ca89b36c0c34d 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.js +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.js @@ -333,7 +333,7 @@ export default function ({ getService }) { expect(apiResponse.body).to.eql({ error: 'Bad Request', - message: 'invalid_grant', + message: 'Both access and refresh tokens are expired.', statusCode: 400 }); }); @@ -390,7 +390,7 @@ export default function ({ getService }) { expect(apiResponse.body).to.eql({ error: 'Bad Request', - message: 'invalid_grant', + message: 'Both access and refresh tokens are expired.', statusCode: 400 }); }); @@ -417,7 +417,7 @@ export default function ({ getService }) { expect(apiResponse.body).to.eql({ error: 'Bad Request', - message: 'invalid_grant', + message: 'Both access and refresh tokens are expired.', statusCode: 400 }); }); @@ -502,5 +502,54 @@ export default function ({ getService }) { .expect(200); }); }); + + describe('API access with missing access token document.', () => { + let sessionCookie; + + beforeEach(async () => { + const handshakeResponse = await supertest.get('/abc/xyz') + .expect(302); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + + const samlAuthenticationResponse = await supertest.post('/api/security/v1/saml') + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) }, {}) + .expect(302); + + sessionCookie = request.cookie(samlAuthenticationResponse.headers['set-cookie'][0]); + }); + + it('should properly set cookie and start new SAML handshake', async function () { + // Let's delete tokens from `.security` index directly to simulate the case when + // Elasticsearch automatically removes access/refresh token document from the index + // after some period of time. + const esResponse = await getService('es').deleteByQuery({ + index: '.security', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse).to.have.property('deleted').greaterThan(0); + + const handshakeResponse = await supertest.get('/abc/xyz/handshake?one=two three') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + 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.headers.location, true /* parseQueryString */); + expect(redirectURL.href.startsWith(`https://elastic.co/sso/saml`)).to.be(true); + expect(redirectURL.query.SAMLRequest).to.not.be.empty(); + }); + }); }); } diff --git a/x-pack/test/saml_api_integration/config.js b/x-pack/test/saml_api_integration/config.js index 2a9e0fe314a00..4ac092d7f09d1 100644 --- a/x-pack/test/saml_api_integration/config.js +++ b/x-pack/test/saml_api_integration/config.js @@ -18,6 +18,7 @@ export default async function ({ readConfigFile }) { servers: xPackAPITestsConfig.get('servers'), services: { chance: kibanaAPITestsConfig.get('services.chance'), + es: kibanaAPITestsConfig.get('services.es'), supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), }, junit: { diff --git a/x-pack/test/token_api_integration/auth/session.js b/x-pack/test/token_api_integration/auth/session.js index 953873a615f53..ad7750f46ec29 100644 --- a/x-pack/test/token_api_integration/auth/session.js +++ b/x-pack/test/token_api_integration/auth/session.js @@ -5,6 +5,7 @@ */ import request from 'request'; +import expect from '@kbn/expect'; const delay = ms => new Promise(resolve => setTimeout(() => resolve(), ms)); @@ -122,5 +123,36 @@ export default function ({ getService }) { .expect(200); }); }); + + describe('API access with missing access token document.', () => { + let sessionCookie; + beforeEach(async () => sessionCookie = await createSessionCookie()); + + it('should clear cookie and redirect to login', async function () { + // Let's delete tokens from `.security` index directly to simulate the case when + // Elasticsearch automatically removes access/refresh token document from the index + // after some period of time. + const esResponse = await getService('es').deleteByQuery({ + index: '.security', + q: 'doc_type:token', + refresh: true, + }); + expect(esResponse).to.have.property('deleted').greaterThan(0); + + const response = await supertest.get('/abc/xyz/') + .set('Cookie', sessionCookie.cookieString()) + .expect('location', '/login?next=%2Fabc%2Fxyz%2F') + .expect(302); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const cookie = request.cookie(cookies[0]); + expect(cookie.key).to.be('sid'); + expect(cookie.value).to.be.empty(); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + }); + }); }); }