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 8971e8ec2d8eb..9ad1450b8400d 100644 --- a/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js @@ -396,7 +396,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__/saml.js b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js index c1c4b7ab1b6b6..15204669e6ae1 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/__tests__/saml.js @@ -222,10 +222,8 @@ 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!'); - callWithRequest - .withArgs(request, 'shield.authenticate') - .returns(Promise.reject(failureReason)); + const failureReason = { statusCode: 500, message: 'Token is not valid!' }; + callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason); const authenticationResult = await provider.authenticate(request, { accessToken: 'some-invalid-token', @@ -247,7 +245,7 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); + .rejects({ statusCode: 401 }); callWithRequest .withArgs( @@ -277,7 +275,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 @@ -285,9 +283,12 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .returns(Promise.reject({ 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', @@ -305,7 +306,7 @@ describe('SAMLAuthenticationProvider', () => { expect(authenticationResult.error).to.be(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 @@ -313,18 +314,18 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .returns(Promise.reject({ 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' } } ) - .returns(Promise.reject({ 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).to.not.have.property('authorization'); @@ -332,7 +333,7 @@ describe('SAMLAuthenticationProvider', () => { expect(authenticationResult.error).to.eql(Boom.badRequest('Both access and refresh tokens are expired.')); }); - 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 @@ -347,18 +348,21 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .returns(Promise.reject({ 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' } } ) - .returns(Promise.reject({ 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( @@ -375,33 +379,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' - ) - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); - - callWithInternalUser - .withArgs( - 'shield.getAccessToken', - { body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' } } - ) - .returns(Promise.reject({ body: { error_description: 'refresh token is expired' } })); - - const authenticationResult = await provider.authenticate(request, { - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token' - }); - - expect(request.headers).to.not.have.property('authorization'); - expect(authenticationResult.failed()).to.be(true); - expect(authenticationResult.error).to.eql(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' }); @@ -417,14 +394,14 @@ describe('SAMLAuthenticationProvider', () => { sinon.match({ headers: { authorization: 'Bearer expired-token' } }), 'shield.authenticate' ) - .returns(Promise.reject({ body: { error: { reason: 'token expired' } } })); + .rejects({ statusCode: 401 }); callWithInternalUser .withArgs( 'shield.getAccessToken', { body: { grant_type: 'refresh_token', refresh_token: 'expired-refresh-token' } } ) - .returns(Promise.reject({ body: { error_description: 'refresh token is expired' } })); + .rejects({ statusCode: 400 }); const authenticationResult = await provider.authenticate(request, { accessToken: 'expired-token', @@ -464,7 +441,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') .returns(Promise.reject(failureReason)); @@ -479,7 +456,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') .returns(Promise.reject(failureReason)); 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 80eed521f565a..a6833694b0f9a 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.js b/x-pack/plugins/security/server/lib/authentication/providers/saml.js index 93b41d231db88..335e8562ef74e 100644 --- a/x-pack/plugins/security/server/lib/authentication/providers/saml.js +++ b/x-pack/plugins/security/server/lib/authentication/providers/saml.js @@ -6,6 +6,7 @@ import Boom from 'boom'; import { canRedirectRequest } from '../../can_redirect_request'; +import { getErrorStatusCode } from '../../errors'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -30,28 +31,22 @@ 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`. - * @param {Object} err Error returned from Elasticsearch. - * @returns {boolean} + * 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 isAccessTokenExpiredError(err) { - return 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`. - * @param {Object} err Error returned from Elasticsearch. - * @returns {boolean} - */ -function isInvalidRefreshTokenError(err) { - return err.body - && (err.body.error_description === 'token has already been refreshed' - || err.body.error_description === 'refresh token is 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') + ); } /** @@ -331,7 +326,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 859974c9ee57c..e94fac1d8c3c8 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 029190048ca0e..c34eca63e0bcd 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 }); }); @@ -551,5 +551,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 63e9df6664675..e22f7feb142bf 100644 --- a/x-pack/test/saml_api_integration/config.js +++ b/x-pack/test/saml_api_integration/config.js @@ -45,6 +45,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 5d678ee485f7e..94fc64ee9dfe9 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 'expect.js'; export default function ({ getService }) { const supertest = getService('supertestWithoutAuth'); @@ -104,5 +105,36 @@ export default function ({ getService }) { .set('Cookie', newCookie.cookieString()) .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); + }); + }); }); }