diff --git a/.eslintrc.json b/.eslintrc.json index a1888fea..a9602b2c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,7 +11,7 @@ "indent": [ "error", 2, - { "SwitchCase": 1 } + { "SwitchCase": 1 } ], "linebreak-style": [ "error", diff --git a/API.md b/API.md index 0405afe9..dc130832 100644 --- a/API.md +++ b/API.md @@ -38,6 +38,7 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`loginPath`** - Relative path to application login. Default is `/login`. - **`logoutPath`** - Relative path to application logout. Default is `/logout`. - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. +- **`postLogoutRedirectUri`** - Either a relative path to the application or a valid URI to an external domain. The user will be redirected to this after a logout has been performed. Default is `baseUrl`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. diff --git a/index.d.ts b/index.d.ts index 320364d5..4fa2dd89 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,6 +123,13 @@ interface ConfigParams { */ redirectUriPath?: string; + /** + * Either a relative path to the application + * or a valid URI to an external domain. + * The user will be redirected to this after a logout has been performed. + */ + postLogoutRedirectUri?: string; + /** * Require authentication for all routes. */ diff --git a/lib/config.js b/lib/config.js index 70289c16..4071503e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -65,6 +65,7 @@ const paramsSchema = Joi.object().keys({ redirectUriPath: Joi.string().uri({relativeOnly: true}).optional().default('/callback'), required: Joi.alternatives([ Joi.func(), Joi.boolean()]).optional().default(true), routes: Joi.boolean().optional().default(true), + postLogoutRedirectUri: Joi.string().uri({allowRelative: true}).optional().default('/') }); function buildAuthorizeParams(authorizationParams) { @@ -119,7 +120,6 @@ module.exports.get = function(params) { } config = paramsValidation.value; - config.authorizationParams = buildAuthorizeParams(config.authorizationParams); config.appSessionCookie = buildAppSessionCookieConfig(config.appSessionCookie); diff --git a/lib/context.js b/lib/context.js index b196c763..70365de0 100644 --- a/lib/context.js +++ b/lib/context.js @@ -1,4 +1,5 @@ const cb = require('cb'); +const url = require('url'); const urlJoin = require('url-join'); const transient = require('./transientHandler'); const { get: getClient } = require('./client'); @@ -88,7 +89,16 @@ class ResponseContext { const next = cb(this._next).once(); const req = this._req; const res = this._res; - const returnURL = params.returnTo || this._config.baseURL; + + let returnURL = params.returnTo || req.query.returnTo || this._config.postLogoutRedirectUri; + + if (url.parse(returnURL).host === null) { + returnURL = urlJoin(this._config.baseURL, returnURL); + } + + if (!req.isAuthenticated()) { + return res.redirect(returnURL); + } req[this._config.appSessionName] = undefined; @@ -96,16 +106,17 @@ class ResponseContext { return res.redirect(returnURL); } + const client = this._req.openid.client; try { - const client = this._req.openid.client; - const url = client.endSessionUrl({ + returnURL = client.endSessionUrl({ post_logout_redirect_uri: returnURL, id_token_hint: req.openid.tokens, }); - res.redirect(url); } catch(err) { - next(err); + return next(err); } + + res.redirect(returnURL); } } diff --git a/test/logout.tests.js b/test/logout.tests.js index 6332b508..8949eeeb 100644 --- a/test/logout.tests.js +++ b/test/logout.tests.js @@ -44,7 +44,7 @@ describe('logout route', function() { it('should redirect to the base url', function() { assert.equal(logoutResponse.statusCode, 302); - assert.equal(logoutResponse.headers.location, 'https://example.org'); + assert.equal(logoutResponse.headers.location, 'https://example.org/'); }); }); @@ -83,15 +83,84 @@ describe('logout route', function() { it('should redirect to the base url', function() { assert.equal(logoutResponse.statusCode, 302); - const parsedUrl = url.parse(logoutResponse.headers.location, true); - assert.deepInclude(parsedUrl, { - protocol: 'https:', - hostname: 'test.auth0.com', - query: { returnTo: 'https://example.org', client_id: '__test_client_id__' }, - pathname: '/v2/logout', - }); + assert.equal(logoutResponse.headers.location, 'https://example.org/'); }); }); -}); + describe('should use postLogoutRedirectUri if present', function() { + describe('should allow relative paths, and prepend with baseURL', () => { + let baseUrl; + const jar = request.jar(); + + before(async function() { + const middleware = auth({ + idpLogout: false, + clientID: '__test_client_id__', + baseURL: 'https://example.org', + issuerBaseURL: 'https://test.auth0.com', + appSessionSecret: '__test_session_secret__', + postLogoutRedirectUri: '/after-logout-in-auth-config', + required: false, + }); + baseUrl = await server.create(middleware); + await request.post({ + uri: '/session', + json: { + openidTokens: { + id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + } + }, + baseUrl, jar + }); + }); + + it('should redirect to postLogoutRedirectUri in auth() config', async function() { + const logoutResponse = await request.get({uri: '/logout', baseUrl, jar, followRedirect: false}); + assert.equal(logoutResponse.headers.location, 'https://example.org/after-logout-in-auth-config'); + }); + + it('should redirect to returnTo in logout query', async function() { + const logoutResponse = await request.get({uri: '/logout', qs: {returnTo: '/after-logout-in-logout-query'}, baseUrl, jar, followRedirect: false}); + assert.equal(logoutResponse.headers.location, 'https://example.org/after-logout-in-logout-query'); + }); + }); + + describe('should allow absolute paths', () => { + let baseUrl; + const jar = request.jar(); + + before(async function() { + const middleware = auth({ + idpLogout: false, + clientID: '__test_client_id__', + baseURL: 'https://example.org', + issuerBaseURL: 'https://test.auth0.com', + appSessionSecret: '__test_session_secret__', + postLogoutRedirectUri: 'https://external-domain.com/after-logout-in-auth-config', + required: false, + }); + baseUrl = await server.create(middleware); + await request.post({ + uri: '/session', + json: { + openidTokens: { + id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + } + }, + baseUrl, jar + }); + }); + + it('should redirect to postLogoutRedirectUri in auth() config', async function() { + const logoutResponse = await request.get({uri: '/logout', baseUrl, jar, followRedirect: false}); + assert.equal(logoutResponse.headers.location, 'https://external-domain.com/after-logout-in-auth-config'); + }); + + it('should redirect to returnTo in logout query', async function() { + const logoutResponse = await request.get({uri: '/logout', qs: {returnTo: 'https://external-domain.com/after-logout-in-logout-query'}, baseUrl, jar, followRedirect: false}); + assert.equal(logoutResponse.headers.location, 'https://external-domain.com/after-logout-in-logout-query'); + }); + }); + }); +}); \ No newline at end of file