diff --git a/.circleci/config.yml b/.circleci/config.yml index db219c7f..6fe9fe22 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ orbs: jobs: build: docker: - - image: circleci/node:14-browsers + - image: cimg/node:lts-browsers environment: LANG: en_US.UTF-8 steps: diff --git a/EXAMPLES.md b/EXAMPLES.md index 9dd2fe6a..c44593ed 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -10,6 +10,7 @@ 8. [Logout from Identity Provider](#8-logout-from-identity-provider) 9. [Validate Claims from an ID token before logging a user in](#9-validate-claims-from-an-id-token-before-logging-a-user-in) 10. [Use a custom session store](#10-use-a-custom-session-store) +11. [Back-Channel Logout](#11-back-channel-logout) ## 1. Basic setup @@ -298,3 +299,50 @@ app.use( ``` Full example at [custom-session-store.js](./examples/custom-session-store.js), to run it: `npm run start:example -- custom-session-store` + +## 11. Back-Channel Logout + +Configure the SDK with `backchannelLogout` enabled. You will also need a session store (like Redis) - you can use any `express-session` compatible store. + +```js +// index.js +const { auth } = require('express-openid-connect'); +const { createClient } = require('redis'); +const RedisStore = require('connect-redis')(auth); + +// redis@v4 +let redisClient = createClient({ legacyMode: true }); +redisClient.connect(); + +app.use( + auth({ + idpLogout: true, + backchannelLogout: { + store: new RedisStore({ client: redisClient }), + }, + }) +); +``` + +If you're already using a session store for stateful sessions you can just reuse that. + +```js +app.use( + auth({ + idpLogout: true, + session: { + store: new RedisStore({ client: redisClient }), + }, + backchannelLogout: true, + }) +); +``` + +### This will: + +- Create the handler `/backchannel-logout` that you can register with your ISP. +- On receipt of a valid Logout Token, the SDK will store an entry by `sid` (Session ID) and an entry by `sub` (User ID) in the `backchannelLogout.store` - the expiry of the entry will be set to the duration of the session (this is customisable using the [onLogoutToken](https://auth0.github.io/express-openid-connect/interfaces/BackchannelLogoutOptions.html#onLogoutToken) config hook) +- On all authenticated requests, the SDK will check the store for an entry that corresponds with the session's ID token's `sid` or `sub`. If it finds a corresponding entry it will invalidate the session and clear the session cookie. (This is customisable using the [isLoggedOut](https://auth0.github.io/express-openid-connect/interfaces/BackchannelLogoutOptions.html#isLoggedOut) config hook) +- If the user logs in again, the SDK will remove any stale `sub` entry in the Back-Channel Logout store to ensure they are not logged out immediately (this is customisable using the [onLogin](https://auth0.github.io/express-openid-connect/interfaces/BackchannelLogoutOptions.html#onLogin) config hook) + +The config options are [documented here](https://auth0.github.io/express-openid-connect/interfaces/BackchannelLogoutOptions.html) diff --git a/end-to-end/backchannel-logout.test.js b/end-to-end/backchannel-logout.test.js new file mode 100644 index 00000000..0558e907 --- /dev/null +++ b/end-to-end/backchannel-logout.test.js @@ -0,0 +1,103 @@ +const { assert } = require('chai'); +const puppeteer = require('puppeteer'); +const request = require('request-promise-native'); +const provider = require('./fixture/oidc-provider'); +const { + baseUrl, + start, + runExample, + stubEnv, + checkContext, + goto, + login, +} = require('./fixture/helpers'); + +describe('back-channel logout', async () => { + let authServer; + let appServer; + let browser; + + beforeEach(async () => { + stubEnv(); + authServer = await start(provider, 3001); + }); + + afterEach(async () => { + authServer.close(); + appServer.close(); + await browser.close(); + }); + + const runTest = async (example) => { + appServer = await runExample(example); + browser = await puppeteer.launch({ + args: ['no-sandbox', 'disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + await goto(baseUrl, page); + assert.match(page.url(), /http:\/\/localhost:300/); + await Promise.all([page.click('a'), page.waitForNavigation()]); + await login('username', 'password', page); + assert.equal( + page.url(), + `${baseUrl}/`, + 'User is returned to the original page' + ); + const loggedInCookies = await page.cookies('http://localhost:3000'); + assert.ok(loggedInCookies.find(({ name }) => name === 'appSession')); + + const response = await checkContext(await page.cookies()); + assert.isOk(response.isAuthenticated); + + await goto(`${baseUrl}/logout-token`, page); + + await page.waitForSelector('pre'); + const element = await page.$('pre'); + const curl = await page.evaluate((el) => el.textContent, element); + const [, logoutToken] = curl.match(/logout_token=([^"]+)/); + const res = await request.post('http://localhost:3000/backchannel-logout', { + form: { + logout_token: logoutToken, + }, + resolveWithFullResponse: true, + }); + assert.equal(res.statusCode, 204); + + await goto(baseUrl, page); + const loggedOutCookies = await page.cookies('http://localhost:3000'); + assert.notOk(loggedOutCookies.find(({ name }) => name === 'appSession')); + }; + + it('should logout via back-channel logout', () => + runTest('backchannel-logout')); + + it('should not logout sub via back-channel logout if user logs in after', async () => { + await runTest('backchannel-logout'); + + await browser.close(); + browser = await puppeteer.launch({ + args: ['no-sandbox', 'disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + await goto(baseUrl, page); + assert.match(page.url(), /http:\/\/localhost:300/); + await Promise.all([page.click('a'), page.waitForNavigation()]); + await login('username', 'password', page); + assert.equal( + page.url(), + `${baseUrl}/`, + 'User is returned to the original page' + ); + + const loggedInCookies = await page.cookies('http://localhost:3000'); + assert.ok(loggedInCookies.find(({ name }) => name === 'appSession')); + const response = await checkContext(await page.cookies()); + assert.isOk(response.isAuthenticated); + }); + + it('should logout via back-channel logout with custom implementation genid', () => + runTest('backchannel-logout-custom-genid')); + + it('should logout via back-channel logout with custom implementation query store', () => + runTest('backchannel-logout-custom-query-store')); +}); diff --git a/end-to-end/fixture/helpers.js b/end-to-end/fixture/helpers.js index 9a9e4ff8..a880e6e3 100644 --- a/end-to-end/fixture/helpers.js +++ b/end-to-end/fixture/helpers.js @@ -1,6 +1,9 @@ const path = require('path'); +const crypto = require('crypto'); const sinon = require('sinon'); const express = require('express'); +const { JWT } = require('jose'); +const { privateJWK } = require('./jwk'); const request = require('request-promise-native').defaults({ json: true }); const baseUrl = 'http://localhost:3000'; @@ -89,6 +92,31 @@ const logout = async (page) => { await Promise.all([page.click('[name=logout]'), page.waitForNavigation()]); }; +const logoutTokenTester = (clientId, sid, sub) => async (req, res) => { + const logoutToken = JWT.sign( + { + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + ...(sid && { sid: req.oidc.user.sid }), + ...(sub && { sub: req.oidc.user.sub }), + }, + privateJWK, + { + issuer: `http://localhost:${process.env.PROVIDER_PORT || 3001}`, + audience: clientId, + iat: true, + jti: crypto.randomBytes(16).toString('hex'), + algorithm: 'RS256', + header: { typ: 'logout+jwt' }, + } + ); + + res.send(` +
curl -X POST http://localhost:3000/backchannel-logout -d "logout_token=${logoutToken}"
+ `); +}; + module.exports = { baseUrl, start, @@ -100,4 +128,5 @@ module.exports = { goto, login, logout, + logoutTokenTester, }; diff --git a/end-to-end/fixture/jwk.js b/end-to-end/fixture/jwk.js new file mode 100644 index 00000000..0358976f --- /dev/null +++ b/end-to-end/fixture/jwk.js @@ -0,0 +1,12 @@ +const { JWK } = require('jose'); + +const key = JWK.generateSync('RSA', 2048, { + alg: 'RS256', + kid: 'key-1', + use: 'sig', +}); + +module.exports.privateJWK = key.toJWK(true); +module.exports.publicJWK = key.toJWK(); +module.exports.privatePEM = key.toPEM(true); +module.exports.publicPEM = key.toPEM(); diff --git a/end-to-end/fixture/oidc-provider.js b/end-to-end/fixture/oidc-provider.js index 169a9973..79c6013c 100644 --- a/end-to-end/fixture/oidc-provider.js +++ b/end-to-end/fixture/oidc-provider.js @@ -1,4 +1,5 @@ const Provider = require('oidc-provider'); +const { privateJWK, publicJWK } = require('./jwk'); const client = { client_id: 'test-express-openid-connect-client-id', @@ -6,11 +7,8 @@ const client = { token_endpoint_auth_method: 'client_secret_post', response_types: ['id_token', 'code', 'code id_token'], grant_types: ['implicit', 'authorization_code', 'refresh_token'], - redirect_uris: [`http://localhost:3000/callback`], - post_logout_redirect_uris: [ - 'http://localhost:3000', - 'http://localhost:3000/custom-logout', - ], + redirect_uris: ['http://localhost:3000/callback'], + post_logout_redirect_uris: ['http://localhost:3000'], }; const config = { @@ -19,21 +17,26 @@ const config = { Object.assign({}, client, { client_id: 'private-key-jwt-client', token_endpoint_auth_method: 'private_key_jwt', - jwks: { - keys: [ - { - kty: 'RSA', - n: '20yjkC7WmelNZN33GAjFaMvKaInjTz3G49eUwizpuAW6Me9_1FMSAK6nM1XI7VBpy_o5-ffNleRIgcvFudZuSvZiAYBBS2HS5F5PjluVExPwHTD7X7CIwqJxq67N5sTeFkh_ZL4fWK-Na4VlFEsKhcjDrLGxhPCuOgr9FmL0u0Vx_TM3Mk3DEhaf-tMlFx-K3R2GRJRe1wnYhOt1sXm8SNUM2uMZI05W6eRFn1gUAdTLNdCTvDY67ZAl6wyOewYo-WGpzwFYXLXDvc-f8vYucRM3Hq_GSzvFQ4l0nRLLj_33vlCg8mB1CEw_LudadzticAir3Ux3bnpno9yndUZR6w', - e: 'AQAB', - }, - ], - }, + jwks: { keys: [publicJWK] }, + }), + Object.assign({}, client, { + client_id: 'backchannel-logout-client', + backchannel_logout_uri: 'http://localhost:3000/backchannel-logout', + backchannel_logout_session_required: true, + }), + Object.assign({}, client, { + client_id: 'backchannel-logout-client-no-sid', + backchannel_logout_uri: 'http://localhost:3000/backchannel-logout', + backchannel_logout_session_required: false, }), Object.assign({}, client, { client_id: 'client-secret-jwt-client', token_endpoint_auth_method: 'client_secret_jwt', }), ], + jwks: { + keys: [privateJWK], + }, formats: { AccessToken: 'jwt', }, @@ -47,6 +50,11 @@ const config = { claims: () => ({ sub: id }), }; }, + features: { + backchannelLogout: { + enabled: true, + }, + }, }; const PORT = process.env.PROVIDER_PORT || 3001; diff --git a/examples/backchannel-logout-custom-genid.js b/examples/backchannel-logout-custom-genid.js new file mode 100644 index 00000000..8593fb15 --- /dev/null +++ b/examples/backchannel-logout-custom-genid.js @@ -0,0 +1,69 @@ +const { promisify } = require('util'); +const crypto = require('crypto'); +const express = require('express'); +const { auth, requiresAuth } = require('../'); +const { logoutTokenTester } = require('../end-to-end/fixture/helpers'); + +// This custom implementation uses a sessions with an id that matches the +// Identity Provider's session id "sid" (by using the "genid" config). +// When the SDK receives a logout token, it can identify the session that needs +// to be destroyed by the logout token's "sid". + +const MemoryStore = require('memorystore')(auth); + +const app = express(); + +const store = new MemoryStore(); +const destroy = promisify(store.destroy).bind(store); + +const onLogoutToken = async (token) => { + const { sid } = token; + // Delete the session - no need to store a logout token. + await destroy(sid); +}; + +app.use( + auth({ + clientID: 'backchannel-logout-client', + authRequired: false, + idpLogout: true, + backchannelLogout: { + onLogoutToken, + isLoggedOut: false, + onLogin: false, + }, + session: { + store, + // If you're using a custom `genid` you should sign the session store cookie + // to ensure it is a cryptographically secure random string and not guessable. + signSessionStoreCookie: true, + genid(req) { + if (req.oidc && req.oidc.isAuthenticated()) { + const { sid } = req.oidc.idTokenClaims; + // Note this must be unique and a cryptographically secure random value. + return sid; + } else { + // Anonymous user sessions (like checkout baskets) + return crypto.randomBytes(16).toString('hex'); + } + }, + }, + }) +); + +app.get('/', async (req, res) => { + if (req.oidc.isAuthenticated()) { + res.send(`hello ${req.oidc.user.sub} logout`); + } else { + res.send('login'); + } +}); + +// For testing purposes only +app.get( + '/logout-token', + requiresAuth(), + logoutTokenTester('backchannel-logout-client', true) +); + +module.exports = app; diff --git a/examples/backchannel-logout-custom-query-store.js b/examples/backchannel-logout-custom-query-store.js new file mode 100644 index 00000000..00fc7c16 --- /dev/null +++ b/examples/backchannel-logout-custom-query-store.js @@ -0,0 +1,68 @@ +const { promisify } = require('util'); +const express = require('express'); +const base64url = require('base64url'); +const { auth, requiresAuth } = require('../'); +const { logoutTokenTester } = require('../end-to-end/fixture/helpers'); + +// This implementation assumes you can query all sessions in the store. +// When you receive a Back-Channel logout request it queries you session store +// for sessions that match the logout token's `sub` or `sid` claim and removes them. + +const MemoryStore = require('memorystore')(auth); + +const app = express(); + +const store = new MemoryStore(); +const all = promisify(store.all).bind(store); +const destroy = promisify(store.destroy).bind(store); + +const decodeJWT = (jwt) => { + const [, payload] = jwt.split('.'); + return JSON.parse(base64url.decode(payload)); +}; + +const onLogoutToken = async (token) => { + const { sid: logoutSid, sub: logoutSub } = token; + // Note: you may not be able to access all sessions in your store + // and this is likely to be an expensive operation if you have lots of sessions. + const allSessions = await all(); + for (const [key, session] of Object.entries(allSessions)) { + // Rather than decode every id token in your store, + // you could store the `sub` and `sid` on the session in `afterCallback`. + const { sub, sid } = decodeJWT(session.data.id_token); + if ((logoutSid && logoutSid === sid) || (logoutSub && logoutSub === sub)) { + await destroy(key); + } + } +}; + +app.use( + auth({ + clientID: 'backchannel-logout-client-no-sid', + authRequired: false, + idpLogout: true, + session: { store }, + backchannelLogout: { + onLogoutToken, + isLoggedOut: false, + onLogin: false, + }, + }) +); + +app.get('/', async (req, res) => { + if (req.oidc.isAuthenticated()) { + res.send(`hello ${req.oidc.user.sub} logout`); + } else { + res.send('login'); + } +}); + +// For testing purposes only +app.get( + '/logout-token', + requiresAuth(), + logoutTokenTester('backchannel-logout-client-no-sid', true, true) +); + +module.exports = app; diff --git a/examples/backchannel-logout.js b/examples/backchannel-logout.js new file mode 100644 index 00000000..54059b19 --- /dev/null +++ b/examples/backchannel-logout.js @@ -0,0 +1,36 @@ +const express = require('express'); +const { auth, requiresAuth } = require('../'); +const { logoutTokenTester } = require('../end-to-end/fixture/helpers'); + +const MemoryStore = require('memorystore')(auth); + +const app = express(); + +app.use( + auth({ + clientID: 'backchannel-logout-client', + authRequired: false, + idpLogout: true, + session: { + store: new MemoryStore(), + }, + backchannelLogout: true, + }) +); + +app.get('/', async (req, res) => { + if (req.oidc.isAuthenticated()) { + res.send(`hello ${req.oidc.user.sub} logout`); + } else { + res.send('login'); + } +}); + +// For testing purposes only +app.get( + '/logout-token', + requiresAuth(), + logoutTokenTester('backchannel-logout-client', false, true) +); + +module.exports = app; diff --git a/examples/private-key-jwt.js b/examples/private-key-jwt.js index 0c5ec2d0..acc7516b 100644 --- a/examples/private-key-jwt.js +++ b/examples/private-key-jwt.js @@ -1,7 +1,6 @@ -const fs = require('fs'); -const path = require('path'); const express = require('express'); const { auth } = require('../'); +const { privateJWK } = require('../end-to-end/fixture/jwk'); const app = express(); @@ -12,9 +11,7 @@ app.use( authorizationParams: { response_type: 'code', }, - clientAssertionSigningKey: fs.readFileSync( - path.join(__dirname, 'private-key.pem') - ), + clientAssertionSigningKey: privateJWK, }) ); diff --git a/examples/private-key.pem b/examples/private-key.pem index d1391f9c..43b71335 100644 --- a/examples/private-key.pem +++ b/examples/private-key.pem @@ -25,4 +25,4 @@ nH64nNQFIFSKsCZIz5q/8TUY0bDY6GsZJnd2YAg4JtkRTY8tPcVjQU9fxxtFJ+X1 ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i ca/T0LLtgmbMmxSv/MmzIg== ------END PRIVATE KEY----- +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 318aa8a0..c59ecb39 100644 --- a/index.d.ts +++ b/index.d.ts @@ -256,6 +256,59 @@ interface CallbackOptions { tokenEndpointParams?: TokenParameters; } +/** + * Custom options to configure Back-Channel Logout on your application. + */ +interface BackchannelLogoutOptions { + /** + * Used to store Back-Channel Logout entries, you can specify a separate store + * for this or just reuse {@link SessionConfigParams.store} if you are using one already. + * + * The store should have `get`, `set` and `destroy` methods, making it compatible + * with [express-session stores](https://github.com/expressjs/session#session-store-implementation). + */ + store?: SessionStore>; + + /** + * On receipt of a Logout Token the SDK validates the token then by default stores 2 entries: one + * by the token's `sid` claim (if available) and one by the token's `sub` claim (if available). + * + * If a session subsequently shows up with either the same `sid` or `sub`, the user if forbidden access and + * their cookie is deleted. + * + * You can override this to implement your own Back-Channel Logout logic + * (See {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-genid.js} or {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-query-store.js}) + */ + onLogoutToken?: ( + decodedToken: object, + config: ConfigParams + ) => Promise | void; + + /** + * When {@link backchannelLogout} is enabled all requests that have a session + * will be checked for a previous Back-Channel logout. By default, this + * uses the `sub` and the `sid` (if available) from the session's ID token to look up a previous logout and + * logs the user out if one is found. + * + * You can override this to implement your own Back-Channel Logout logic + * (See {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-genid.js} or {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-query-store.js}) + */ + isLoggedOut?: + | false + | ((req: Request, config: ConfigParams) => Promise | boolean); + + /** + * When {@link backchannelLogout} is enabled, upon successful login the SDK will remove any existing Back-Channel + * logout entries for the same `sub`, to prevent the user from being logged out by an old Back-Channel logout. + * + * You can override this to implement your own Back-Channel Logout logic + * (See {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-genid.js} or {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-query-store.js}) + */ + onLogin?: + | false + | ((req: Request, config: ConfigParams) => Promise | void); +} + /** * Configuration parameters passed to the `auth()` middleware. * @@ -484,6 +537,21 @@ interface ConfigParams { */ pushedAuthorizationRequests?: boolean; + /** + * Set to `true` to enable Back-Channel Logout in your application. + * This will set up a web hook on your app at {@link ConfigParams.routes routes.backchannelLogout} + * On receipt of a Logout Token the webhook will store the token, then on any + * subsequent requests, will check the store for a Logout Token that corresponds to the + * current session. If it finds one, it will log the user out. + * + * In order for this to work you need to specify a {@link ConfigParams.backchannelLogout.store}, + * which can be any `express-session` compatible store, or you can + * reuse {@link SessionConfigParams.store} if you are using one already. + * + * See: https://openid.net/specs/openid-connect-backchannel-1_0.html + */ + backchannelLogout?: boolean | BackchannelLogoutOptions; + /** * Configuration for the login, logout, callback and postLogoutRedirect routes. */ @@ -509,6 +577,11 @@ interface ConfigParams { * Relative path to the application callback to process the response from the authorization server. */ callback?: string | false; + + /** + * Relative path to the application's Back-Channel Logout web hook. + */ + backchannelLogout?: string; }; /** @@ -619,7 +692,7 @@ interface ConfigParams { httpUserAgent?: string; } -interface SessionStorePayload { +interface SessionStorePayload { header: { /** * timestamp (in secs) when the session was created. @@ -638,16 +711,25 @@ interface SessionStorePayload { /** * The session data. */ - data: Session; + data: Data; + + /** + * This makes it compatible with some `express-session` stores that use this + * to set their ttl. + */ + cookie: { + expires: number; + maxAge: number; + }; } -interface SessionStore { +interface SessionStore { /** * Gets the session from the store given a session ID and passes it to `callback`. */ get( sid: string, - callback: (err: any, session?: SessionStorePayload | null) => void + callback: (err: any, session?: SessionStorePayload | null) => void ): void; /** @@ -655,7 +737,7 @@ interface SessionStore { */ set( sid: string, - session: SessionStorePayload, + session: SessionStorePayload, callback?: (err?: any) => void ): void; diff --git a/lib/client.js b/lib/client.js index 4d73e406..455e3102 100644 --- a/lib/client.js +++ b/lib/client.js @@ -145,7 +145,7 @@ async function get(config) { } } - return client; + return { client, issuer }; } const cache = new Map(); diff --git a/lib/config.js b/lib/config.js index 3e73a9ae..25095f6d 100644 --- a/lib/config.js +++ b/lib/config.js @@ -43,7 +43,20 @@ const paramsSchema = Joi.object({ .pattern(/^[0-9a-zA-Z_.-]+$/, { name: 'cookie name' }) .optional() .default('appSession'), - store: Joi.object().optional(), + store: Joi.object() + .optional() + .when(Joi.ref('/backchannelLogout'), { + not: false, + then: Joi.when('/backchannelLogout.store', { + not: Joi.exist(), + then: Joi.when('/backchannelLogout.isLoggedOut', { + not: Joi.exist(), + then: Joi.object().required().messages({ + 'any.required': `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions) or custom hooks for "isLoggedOut" and "onLogoutToken".`, + }), + }), + }), + }), genid: Joi.function() .maxArity(1) .optional() @@ -116,6 +129,21 @@ const paramsSchema = Joi.object({ .unknown(true) .default(), logoutParams: Joi.object().optional(), + backchannelLogout: Joi.alternatives([ + Joi.object({ + store: Joi.object().optional(), + onLogin: Joi.alternatives([ + Joi.function(), + Joi.boolean().valid(false), + ]).optional(), + isLoggedOut: Joi.alternatives([ + Joi.function(), + Joi.boolean().valid(false), + ]).optional(), + onLogoutToken: Joi.function().optional(), + }), + Joi.boolean(), + ]).default(false), baseURL: Joi.string() .uri() .required() @@ -200,6 +228,9 @@ const paramsSchema = Joi.object({ Joi.boolean().valid(false), ]).default('/callback'), postLogoutRedirect: Joi.string().uri({ allowRelative: true }).default(''), + backchannelLogout: Joi.string() + .uri({ allowRelative: true }) + .default('/backchannel-logout'), }) .default() .unknown(false), diff --git a/lib/context.js b/lib/context.js index ebefe28d..05b81544 100644 --- a/lib/context.js +++ b/lib/context.js @@ -1,7 +1,9 @@ const url = require('url'); const urlJoin = require('url-join'); +const { JWT } = require('jose'); const { TokenSet } = require('openid-client'); const clone = require('clone'); + const { strict: assert } = require('assert'); const createError = require('http-errors'); @@ -9,6 +11,8 @@ const debug = require('./debug')('context'); const { once } = require('./once'); const { get: getClient } = require('./client'); const { encodeState, decodeState } = require('../lib/hooks/getLoginState'); +const onLogin = require('./hooks/backchannelLogout/onLogIn'); +const onLogoutToken = require('./hooks/backchannelLogout/onLogoutToken'); const { cancelSilentLogin, resumeSilentLogin, @@ -25,7 +29,7 @@ function isExpired() { async function refresh({ tokenEndpointParams } = {}) { let { config, req } = weakRef(this); - const client = await getClient(config); + const { client } = await getClient(config); const oldTokenSet = tokenSet.call(this); let extras; @@ -127,7 +131,15 @@ class RequestContext { get idTokenClaims() { try { - return clone(tokenSet.call(this).claims()); + const { + config: { session }, + req, + } = weakRef(this); + + // The ID Token from Auth0's Refresh Grant doesn't contain a "sid" + // so we should check the backup sid we stored at login. + const { sid } = req[session.name]; + return { sid, ...clone(tokenSet.call(this).claims()) }; } catch (err) { return undefined; } @@ -152,7 +164,7 @@ class RequestContext { async fetchUserInfo() { const { config } = weakRef(this); - const client = await getClient(config); + const { client } = await getClient(config); return client.userinfo(tokenSet.call(this)); } } @@ -185,7 +197,7 @@ class ResponseContext { let { config, req, res, next, transient } = weakRef(this); next = once(next); try { - const client = await getClient(config); + const { client } = await getClient(config); // Set default returnTo value, allow passed-in options to override or use originalUrl on GET let returnTo = config.baseURL; @@ -289,7 +301,7 @@ class ResponseContext { debug('req.oidc.logout() with return url: %s', returnURL); try { - const client = await getClient(config); + const { client } = await getClient(config); if (url.parse(returnURL).host === null) { returnURL = urlJoin(config.baseURL, returnURL); @@ -328,7 +340,7 @@ class ResponseContext { let { config, req, res, transient, next } = weakRef(this); next = once(next); try { - const client = await getClient(config); + const { client } = await getClient(config); const redirectUri = options.redirectUri || this.getRedirectUri(); let tokenSet; @@ -358,6 +370,10 @@ class ResponseContext { } let session = Object.assign({}, tokenSet); // Remove non-enumerable methods from the TokenSet + const claims = tokenSet.claims(); + // Must store the `sid` separately as the ID Token gets overridden by + // ID Token from the Refresh Grant which may not contain a sid (In Auth0 currently). + session.sid = claims.sid; if (config.afterCallback) { session = await config.afterCallback( @@ -369,7 +385,7 @@ class ResponseContext { } if (req.oidc.isAuthenticated()) { - if (req.oidc.user.sub === tokenSet.claims().sub) { + if (req.oidc.user.sub === claims.sub) { // If it's the same user logging in again, just update the existing session. Object.assign(req[config.session.name], session); } else { @@ -387,6 +403,14 @@ class ResponseContext { await regenerateSessionStoreId(req, config); } resumeSilentLogin(req, res); + + if ( + req.oidc.isAuthenticated() && + config.backchannelLogout && + config.backchannelLogout.onLogin !== false + ) { + await (config.backchannelLogout.onLogin || onLogin)(req, config); + } } catch (err) { if (!req.openidState || !req.openidState.attemptingSilentLogin) { return next(err); @@ -394,6 +418,50 @@ class ResponseContext { } res.redirect(req.openidState.returnTo || config.baseURL); } + + async backchannelLogout() { + let { config, req, res } = weakRef(this); + res.setHeader('cache-control', 'no-store'); + const logoutToken = req.body.logout_token; + if (!logoutToken) { + res.status(400).json({ + error: 'invalid_request', + error_description: 'Missing logout_token', + }); + return; + } + const onToken = + (config.backchannelLogout && config.backchannelLogout.onLogoutToken) || + onLogoutToken; + let token; + try { + const { issuer } = await getClient(config); + const keyInput = await issuer.keystore(); + + token = await JWT.LogoutToken.verify(logoutToken, keyInput, { + issuer: issuer.issuer, + audience: config.clientID, + algorithms: [config.idTokenSigningAlg], + }); + } catch (e) { + res.status(400).json({ + error: 'invalid_request', + error_description: e.message, + }); + return; + } + try { + await onToken(token, config); + } catch (e) { + debug('req.oidc.backchannelLogout() failed with: %s', e.message); + res.status(400).json({ + error: 'application_error', + error_description: `The application failed to invalidate the session.`, + }); + return; + } + res.status(204).send(); + } } module.exports = { RequestContext, ResponseContext }; diff --git a/lib/hooks/backchannelLogout/isLoggedOut.js b/lib/hooks/backchannelLogout/isLoggedOut.js new file mode 100644 index 00000000..00428a12 --- /dev/null +++ b/lib/hooks/backchannelLogout/isLoggedOut.js @@ -0,0 +1,22 @@ +const { promisify } = require('util'); +const { get: getClient } = require('../../client'); + +// Default hook that checks if the user has been logged out via Back-Channel Logout +module.exports = async (req, config) => { + const store = + (config.backchannelLogout && config.backchannelLogout.store) || + config.session.store; + const get = promisify(store.get).bind(store); + const { + issuer: { issuer }, + } = await getClient(config); + const { sid, sub } = req.oidc.idTokenClaims; + if (!sid && !sub) { + throw new Error(`The session must have a 'sid' or a 'sub'`); + } + const [logoutSid, logoutSub] = await Promise.all([ + sid && get(`${issuer}|${sid}`), + sub && get(`${issuer}|${sub}`), + ]); + return !!(logoutSid || logoutSub); +}; diff --git a/lib/hooks/backchannelLogout/onLogIn.js b/lib/hooks/backchannelLogout/onLogIn.js new file mode 100644 index 00000000..d72bcb29 --- /dev/null +++ b/lib/hooks/backchannelLogout/onLogIn.js @@ -0,0 +1,13 @@ +const { promisify } = require('util'); +const { get: getClient } = require('../../client'); + +// Remove any Back-Channel Logout tokens for this `sub` +module.exports = async (req, config) => { + const { + issuer: { issuer }, + } = await getClient(config); + const { session, backchannelLogout } = config; + const store = (backchannelLogout && backchannelLogout.store) || session.store; + const destroy = promisify(store.destroy).bind(store); + await destroy(`${issuer}|${req.oidc.idTokenClaims.sub}`); +}; diff --git a/lib/hooks/backchannelLogout/onLogoutToken.js b/lib/hooks/backchannelLogout/onLogoutToken.js new file mode 100644 index 00000000..f9be8fd2 --- /dev/null +++ b/lib/hooks/backchannelLogout/onLogoutToken.js @@ -0,0 +1,39 @@ +const { promisify } = require('util'); + +// Default hook stores an entry in the logout store for `sid` (if available) and `sub` (if available). +module.exports = async (token, config) => { + const { + session: { + absoluteDuration, + rolling: rollingEnabled, + rollingDuration, + store, + }, + backchannelLogout, + } = config; + const backchannelLogoutStore = + (backchannelLogout && backchannelLogout.store) || store; + const maxAge = + (rollingEnabled + ? Math.min(absoluteDuration, rollingDuration) + : absoluteDuration) * 1000; + const payload = { + // The "cookie" prop makes the payload compatible with + // `express-session` stores. + cookie: { + expires: Date.now() + maxAge, + maxAge, + }, + }; + const set = promisify(backchannelLogoutStore.set).bind( + backchannelLogoutStore + ); + const { iss, sid, sub } = token; + if (!sid && !sub) { + throw new Error(`The Logout Token must have a 'sid' or a 'sub'`); + } + await Promise.all([ + sid && set(`${iss}|${sid}`, payload), + sub && set(`${iss}|${sub}`, payload), + ]); +}; diff --git a/middleware/auth.js b/middleware/auth.js index f071dcb3..651342b2 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -7,6 +7,7 @@ const attemptSilentLogin = require('./attemptSilentLogin'); const TransientCookieHandler = require('../lib/transientHandler'); const { RequestContext, ResponseContext } = require('../lib/context'); const appSession = require('../lib/appSession'); +const isLoggedOut = require('../lib/hooks/backchannelLogout/isLoggedOut'); const enforceLeadingSlash = (path) => { return path.split('')[0] === '/' ? path : '/' + path; @@ -67,6 +68,33 @@ const auth = function (params) { debug('callback handling route not applied'); } + if (config.backchannelLogout) { + const path = enforceLeadingSlash(config.routes.backchannelLogout); + debug('adding POST %s route', path); + router.post(path, express.urlencoded({ extended: false }), (req, res) => + res.oidc.backchannelLogout() + ); + + if (config.backchannelLogout.isLoggedOut !== false) { + const isLoggedOutFn = config.backchannelLogout.isLoggedOut || isLoggedOut; + router.use(async (req, res, next) => { + if (!req.oidc.isAuthenticated()) { + next(); + return; + } + try { + const loggedOut = await isLoggedOutFn(req, config); + if (loggedOut) { + req[config.session.name] = undefined; + } + next(); + } catch (e) { + next(e); + } + }); + } + } + if (config.authRequired) { debug( 'authentication is required for all routes this middleware is applied to' diff --git a/package-lock.json b/package-lock.json index 23550b16..d36160b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "dotenv": "^8.2.0", "eslint": "^5.16.0", "express": "^4.18.2", - "express-oauth2-jwt-bearer": "^1.1.0", + "express-oauth2-jwt-bearer": "^1.5.0", "husky": "^4.2.5", "lodash": "^4.17.15", "memorystore": "^1.6.4", @@ -2699,21 +2699,21 @@ } }, "node_modules/express-oauth2-jwt-bearer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.1.0.tgz", - "integrity": "sha512-T9sSmGftzMACOH1oY2gniHkiJ53dWjPgIUD/CrJDL5Ss5PeX+PAol53upd7eaKLiLn/vp+AMTefxkkDIPEJXBQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.5.0.tgz", + "integrity": "sha512-C8avk1VfopX3zjqfTLg9EuYFjNRdmXdYncBoZGmjxQzGx7cQRiupeWV5r3G2SYGzx0gDw1uyu1cdJrmILOvd3g==", "dev": true, "dependencies": { - "jose": "^4.3.7" + "jose": "^4.13.1" }, "engines": { - "node": "12.19.0 || ^14.15.0 || ^16.13.0" + "node": "^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0 || ^20.2.0" } }, "node_modules/express-oauth2-jwt-bearer/node_modules/jose": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.3.tgz", - "integrity": "sha512-7rySkpW78d8LBp4YU70Wb7+OTgE3OwAALNVZxhoIhp4Kscp+p/fBkdpxGAMKxvCAMV4QfXBU9m6l9nX/vGwd2g==", + "version": "4.14.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.6.tgz", + "integrity": "sha512-EqJPEUlZD0/CSUMubKtMaYUOtWe91tZXTWMJZoKSbLk+KtdhNdcvppH8lA9XwVu2V4Ailvsj0GBZJ2ZwDjfesQ==", "dev": true, "funding": { "url": "https://github.com/sponsors/panva" @@ -10597,18 +10597,18 @@ } }, "express-oauth2-jwt-bearer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.1.0.tgz", - "integrity": "sha512-T9sSmGftzMACOH1oY2gniHkiJ53dWjPgIUD/CrJDL5Ss5PeX+PAol53upd7eaKLiLn/vp+AMTefxkkDIPEJXBQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.5.0.tgz", + "integrity": "sha512-C8avk1VfopX3zjqfTLg9EuYFjNRdmXdYncBoZGmjxQzGx7cQRiupeWV5r3G2SYGzx0gDw1uyu1cdJrmILOvd3g==", "dev": true, "requires": { - "jose": "^4.3.7" + "jose": "^4.13.1" }, "dependencies": { "jose": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.3.tgz", - "integrity": "sha512-7rySkpW78d8LBp4YU70Wb7+OTgE3OwAALNVZxhoIhp4Kscp+p/fBkdpxGAMKxvCAMV4QfXBU9m6l9nX/vGwd2g==", + "version": "4.14.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.6.tgz", + "integrity": "sha512-EqJPEUlZD0/CSUMubKtMaYUOtWe91tZXTWMJZoKSbLk+KtdhNdcvppH8lA9XwVu2V4Ailvsj0GBZJ2ZwDjfesQ==", "dev": true } } diff --git a/package.json b/package.json index 78927b00..4ecb2f7c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "dotenv": "^8.2.0", "eslint": "^5.16.0", "express": "^4.18.2", - "express-oauth2-jwt-bearer": "^1.1.0", + "express-oauth2-jwt-bearer": "^1.5.0", "husky": "^4.2.5", "lodash": "^4.17.15", "memorystore": "^1.6.4", diff --git a/test/appSession.customStore.tests.js b/test/appSession.customStore.tests.js index c7723ae9..e0d147ce 100644 --- a/test/appSession.customStore.tests.js +++ b/test/appSession.customStore.tests.js @@ -1,4 +1,3 @@ -const { promisify } = require('util'); const express = require('express'); const { assert } = require('chai').use(require('chai-as-promised')); const request = require('request-promise-native').defaults({ @@ -9,9 +8,8 @@ const request = require('request-promise-native').defaults({ const appSession = require('../lib/appSession'); const { get: getConfig } = require('../lib/config'); const { create: createServer } = require('./fixture/server'); -const redis = require('redis-mock'); const { getKeyStore, signCookie } = require('../lib/crypto'); -const RedisStore = require('connect-redis')({ Store: class Store {} }); +const getRedisStore = require('./fixture/store'); const defaultConfig = { clientID: '__test_client_id__', @@ -58,12 +56,8 @@ describe('appSession custom store', () => { let signedCookieValue; const setup = async (config) => { - redisClient = redis.createClient(); - const store = new RedisStore({ client: redisClient, prefix: '' }); - redisClient.asyncSet = promisify(redisClient.set).bind(redisClient); - redisClient.asyncGet = promisify(redisClient.get).bind(redisClient); - redisClient.asyncDbsize = promisify(redisClient.dbsize).bind(redisClient); - redisClient.asyncTtl = promisify(redisClient.ttl).bind(redisClient); + const { client, store } = getRedisStore(); + redisClient = client; const conf = getConfig({ ...defaultConfig, @@ -232,8 +226,7 @@ describe('appSession custom store', () => { json: true, }); assert.equal(res.statusCode, 200); - const storedSessionJson = await redisClient.asyncGet(immId); - const { data: sessionValues } = JSON.parse(storedSessionJson); + const { data: sessionValues } = await redisClient.asyncGet(immId); assert.deepEqual(sessionValues, { sub: '__foo_user__', role: 'test', @@ -278,9 +271,9 @@ describe('appSession custom store', () => { it('should not throw if another mw writes the header', async () => { const app = express(); - redisClient = redis.createClient(); - const store = new RedisStore({ client: redisClient, prefix: '' }); - await promisify(redisClient.set).bind(redisClient)('foo', sessionData()); + const { client, store } = getRedisStore(); + redisClient = client; + await redisClient.set('foo', sessionData()); const conf = getConfig({ ...defaultConfig, diff --git a/test/backchannelLogout.tests.js b/test/backchannelLogout.tests.js new file mode 100644 index 00000000..951b0600 --- /dev/null +++ b/test/backchannelLogout.tests.js @@ -0,0 +1,289 @@ +const { assert } = require('chai'); +const onLogin = require('../lib/hooks/backchannelLogout/onLogIn'); +const { get: getConfig } = require('../lib/config'); +const { create: createServer } = require('./fixture/server'); +const { makeIdToken, makeLogoutToken } = require('./fixture/cert'); +const { auth } = require('./..'); +const getRedisStore = require('./fixture/store'); + +const baseUrl = 'http://localhost:3000'; + +const request = require('request-promise-native').defaults({ + simple: false, + resolveWithFullResponse: true, + baseUrl, + json: true, +}); + +const login = async (idToken) => { + const jar = request.jar(); + await request.post({ + uri: '/session', + json: { + id_token: idToken || makeIdToken(), + }, + jar, + }); + + const session = (await request.get({ uri: '/session', jar })).body; + return { jar, session }; +}; + +describe('back-channel logout', async () => { + let server; + let client; + let store; + let config; + + beforeEach(() => { + ({ client, store } = getRedisStore()); + config = { + clientID: '__test_client_id__', + baseURL: 'http://example.org', + issuerBaseURL: 'https://op.example.com', + secret: '__test_session_secret__', + authRequired: false, + backchannelLogout: { store }, + }; + }); + + afterEach(async () => { + if (server) { + server.close(); + } + if (client) { + await new Promise((resolve) => client.flushall(resolve)); + await new Promise((resolve) => client.quit(resolve)); + } + }); + + it('should only handle post requests', async () => { + server = await createServer(auth(config)); + + for (const method of ['get', 'put', 'patch', 'delete']) { + const res = await request('/backchannel-logout', { + method, + }); + assert.equal(res.statusCode, 404); + } + }); + + it('should require a logout token', async () => { + server = await createServer(auth(config)); + + const res = await request.post('/backchannel-logout'); + assert.equal(res.statusCode, 400); + assert.deepEqual(res.body, { + error: 'invalid_request', + error_description: 'Missing logout_token', + }); + }); + + it('should not cache the response', async () => { + server = await createServer(auth(config)); + + const res = await request.post('/backchannel-logout'); + assert.equal(res.headers['cache-control'], 'no-store'); + }); + + it('should accept and store a valid logout_token', async () => { + server = await createServer(auth(config)); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken({ sid: 'foo' }), + }, + }); + assert.equal(res.statusCode, 204); + const payload = await client.asyncGet('https://op.example.com/|foo'); + assert.ok(payload); + }); + + it('should accept and store a valid logout_token signed with HS256', async () => { + server = await createServer(auth(config)); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken({ + sid: 'foo', + secret: config.clientSecret, + }), + }, + }); + assert.equal(res.statusCode, 204); + const payload = await client.asyncGet('https://op.example.com/|foo'); + assert.ok(payload); + }); + + it('should require a sid or a sub', async () => { + server = await createServer(auth(config)); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken(), + }, + }); + assert.equal(res.statusCode, 400); + }); + + it('should set a maxAge based on rolling expiry', async () => { + server = await createServer( + auth({ ...config, session: { rollingDuration: 999 } }) + ); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken({ sid: 'foo' }), + }, + }); + assert.equal(res.statusCode, 204); + const { cookie } = await client.asyncGet('https://op.example.com/|foo'); + assert.equal(cookie.maxAge, 999 * 1000); + const ttl = await client.asyncTtl('https://op.example.com/|foo'); + assert.closeTo(ttl, 999, 5); + }); + + it('should set a maxAge based on absolute expiry', async () => { + server = await createServer( + auth({ ...config, session: { absoluteDuration: 999, rolling: false } }) + ); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken({ sid: 'foo' }), + }, + }); + assert.equal(res.statusCode, 204); + const { cookie } = await client.asyncGet('https://op.example.com/|foo'); + assert.equal(cookie.maxAge, 999 * 1000); + const ttl = await client.asyncTtl('https://op.example.com/|foo'); + assert.closeTo(ttl, 999, 5); + }); + + it('should fail if storing the token fails', async () => { + server = await createServer( + auth({ + ...config, + backchannelLogout: { + ...config.backchannelLogout, + onLogoutToken() { + throw new Error('storage failure'); + }, + }, + }) + ); + + const res = await request.post('/backchannel-logout', { + form: { + logout_token: makeLogoutToken({ sid: 'foo' }), + }, + }); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'application_error'); + }); + + it('should log sid out on subsequent requests', async () => { + server = await createServer(auth(config)); + const { jar } = await login(makeIdToken({ sid: '__foo_sid__' })); + let body; + ({ body } = await request.get('/session', { + jar, + })); + assert.isNotEmpty(body); + assert.isNotEmpty(jar.getCookies(baseUrl)); + + const res = await request.post('/backchannel-logout', { + baseUrl, + form: { + logout_token: makeLogoutToken({ sid: '__foo_sid__' }), + }, + }); + assert.equal(res.statusCode, 204); + const payload = await client.asyncGet( + 'https://op.example.com/|__foo_sid__' + ); + assert.ok(payload); + ({ body } = await request.get('/session', { + jar, + })); + assert.isEmpty(jar.getCookies(baseUrl)); + assert.isUndefined(body); + }); + + it('should log sub out on subsequent requests', async () => { + server = await createServer(auth(config)); + const { jar } = await login(makeIdToken({ sub: '__foo_sub__' })); + let body; + ({ body } = await request.get('/session', { + jar, + })); + assert.isNotEmpty(body); + assert.isNotEmpty(jar.getCookies(baseUrl)); + + const res = await request.post('/backchannel-logout', { + baseUrl, + form: { + logout_token: makeLogoutToken({ sub: '__foo_sub__' }), + }, + }); + assert.equal(res.statusCode, 204); + const payload = await client.asyncGet( + 'https://op.example.com/|__foo_sub__' + ); + assert.ok(payload); + ({ body } = await request.get('/session', { + jar, + })); + assert.isEmpty(jar.getCookies(baseUrl)); + assert.isUndefined(body); + }); + + it('should not log sub out if login is after back-channel logout', async () => { + server = await createServer(auth(config)); + + const { jar } = await login(makeIdToken({ sub: '__foo_sub__' })); + + const res = await request.post('/backchannel-logout', { + baseUrl, + form: { + logout_token: makeLogoutToken({ sub: '__foo_sub__' }), + }, + }); + assert.equal(res.statusCode, 204); + let payload = await client.asyncGet('https://op.example.com/|__foo_sub__'); + assert.ok(payload); + + await onLogin( + { oidc: { idTokenClaims: { sub: '__foo_sub__' } } }, + getConfig(config) + ); + payload = await client.asyncGet('https://op.example.com/|__foo_sub__'); + assert.notOk(payload); + + const { body } = await request.get('/session', { + jar, + }); + assert.isNotEmpty(jar.getCookies(baseUrl)); + assert.isNotEmpty(body); + }); + + it('should handle failures to get logout token', async () => { + server = await createServer( + auth({ + ...config, + backchannelLogout: { + ...config.backchannelLogout, + isLoggedOut() { + throw new Error('storage failure'); + }, + }, + }) + ); + const { jar } = await login(makeIdToken({ sid: '__foo_sid__' })); + let body; + ({ body } = await request.get('/session', { + jar, + })); + assert.deepEqual(body, { err: { message: 'storage failure' } }); + }); +}); diff --git a/test/callback.tests.js b/test/callback.tests.js index d379e869..07a3c21e 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -16,9 +16,7 @@ const clientID = '__test_client_id__'; const expectedDefaultState = encodeState({ returnTo: 'https://example.org' }); const nock = require('nock'); const MemoryStore = require('memorystore')(auth); -const privateKey = require('fs').readFileSync( - require('path').join(__dirname, '../examples', 'private-key.pem') -); +const { privatePEM: privateKey } = require('../end-to-end/fixture/jwk'); const baseUrl = 'http://localhost:3000'; const defaultConfig = { @@ -599,6 +597,79 @@ describe('callback response_mode: form_post', () => { ); }); + it('should retain sid after token refresh', async () => { + const idTokenWithSid = makeIdToken({ + c_hash: '77QmUPtjPfzWtF2AnpK9RQ', + sid: 'foo', + }); + const idTokenNoSid = makeIdToken({ + c_hash: '77QmUPtjPfzWtF2AnpK9RQ', + }); + + const authOpts = { + ...defaultConfig, + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code id_token', + audience: 'https://api.example.com/', + scope: 'openid profile email read:reports offline_access', + }, + }; + const router = auth(authOpts); + router.get('/refresh', async (req, res) => { + const accessToken = await req.oidc.accessToken.refresh(); + res.json({ + accessToken, + refreshToken: req.oidc.refreshToken, + }); + }); + + const { jar } = await setup({ + router, + authOpts: { + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code id_token', + audience: 'https://api.example.com/', + scope: 'openid profile email read:reports offline_access', + }, + }, + cookies: generateCookies({ + state: expectedDefaultState, + nonce: '__test_nonce__', + }), + body: { + state: expectedDefaultState, + id_token: idTokenWithSid, + code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y', + }, + }); + + const reply = sinon.spy(() => ({ + access_token: '__new_access_token__', + refresh_token: '__new_refresh_token__', + id_token: idTokenNoSid, + token_type: 'Bearer', + expires_in: 86400, + })); + const { + interceptors: [interceptor], + } = nock('https://op.example.com', { allowUnmocked: true }) + .post('/oauth/token') + .reply(200, reply); + + await request.get('/refresh', { baseUrl, jar }); + const { body: newTokens } = await request.get('/tokens', { + baseUrl, + jar, + json: true, + }); + nock.removeInterceptor(interceptor); + + assert.equal(newTokens.accessToken.access_token, '__new_access_token__'); + assert.equal(newTokens.idTokenClaims.sid, 'foo'); + }); + it('should refresh an access token and keep original refresh token', async () => { const idToken = makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ', diff --git a/test/client.tests.js b/test/client.tests.js index d608182e..99e205a5 100644 --- a/test/client.tests.js +++ b/test/client.tests.js @@ -30,7 +30,7 @@ describe('client initialization', function () { let client; beforeEach(async function () { - client = await getClient(config); + ({ client } = await getClient(config)); }); it('should save the passed values', async function () { @@ -99,7 +99,7 @@ describe('client initialization', function () { }) ); - const client = await getClient(config); + const { client } = await getClient(config); assert.equal(client.id_token_signed_response_alg, 'RS256'); }); }); @@ -115,12 +115,14 @@ describe('client initialization', function () { }; it('should use discovered logout endpoint by default', async function () { - const client = await getClient(getConfig(base)); + const { client } = await getClient(getConfig(base)); assert.equal(client.endSessionUrl({}), wellKnown.end_session_endpoint); }); it('should use auth0 logout endpoint if configured', async function () { - const client = await getClient(getConfig({ ...base, auth0Logout: true })); + const { client } = await getClient( + getConfig({ ...base, auth0Logout: true }) + ); assert.equal( client.endSessionUrl({}), 'https://op.example.com/v2/logout?client_id=__test_client_id__' @@ -131,7 +133,7 @@ describe('client initialization', function () { nock('https://foo.auth0.com') .get('/.well-known/openid-configuration') .reply(200, { ...wellKnown, issuer: 'https://foo.auth0.com/' }); - const client = await getClient( + const { client } = await getClient( getConfig({ ...base, issuerBaseURL: 'https://foo.auth0.com' }) ); assert.equal( @@ -144,7 +146,7 @@ describe('client initialization', function () { nock('https://foo.auth0.com') .get('/.well-known/openid-configuration') .reply(200, { ...wellKnown, issuer: 'https://foo.auth0.com/' }); - const client = await getClient( + const { client } = await getClient( getConfig({ ...base, issuerBaseURL: 'https://foo.auth0.com', @@ -165,7 +167,7 @@ describe('client initialization', function () { issuer: 'https://foo.auth0.com/', end_session_endpoint: 'https://foo.auth0.com/oidc/logout', }); - const client = await getClient( + const { client } = await getClient( getConfig({ ...base, issuerBaseURL: 'https://foo.auth0.com', @@ -186,7 +188,7 @@ describe('client initialization', function () { issuer: 'https://op2.example.com', end_session_endpoint: undefined, }); - const client = await getClient( + const { client } = await getClient( getConfig({ ...base, issuerBaseURL: 'https://op2.example.com' }) ); assert.throws(() => client.endSessionUrl({})); @@ -221,21 +223,21 @@ describe('client initialization', function () { it('should not timeout for default', async function () { mockRequest(0); - const client = await getClient({ ...config }); + const { client } = await getClient({ ...config }); const response = await invokeRequest(client); assert.equal(response.statusCode, 200); }); it('should not timeout for delay < httpTimeout', async function () { mockRequest(1000); - const client = await getClient({ ...config, httpTimeout: 1500 }); + const { client } = await getClient({ ...config, httpTimeout: 1500 }); const response = await invokeRequest(client); assert.equal(response.statusCode, 200); }); it('should timeout for delay > httpTimeout', async function () { mockRequest(1500); - const client = await getClient({ ...config, httpTimeout: 500 }); + const { client } = await getClient({ ...config, httpTimeout: 500 }); await expect(invokeRequest(client)).to.be.rejectedWith( `Timeout awaiting 'request' for 500ms` ); @@ -254,7 +256,7 @@ describe('client initialization', function () { it('should send default UA header', async function () { const handler = sinon.stub().returns([200]); nock('https://op.example.com').get('/foo').reply(handler); - const client = await getClient({ ...config }); + const { client } = await getClient({ ...config }); await client.requestResource('https://op.example.com/foo'); expect(handler.firstCall.thisValue.req.headers['user-agent']).to.match( /^express-openid-connect\// @@ -264,7 +266,7 @@ describe('client initialization', function () { it('should send custom UA header', async function () { const handler = sinon.stub().returns([200]); nock('https://op.example.com').get('/foo').reply(handler); - const client = await getClient({ ...config, httpUserAgent: 'foo' }); + const { client } = await getClient({ ...config, httpUserAgent: 'foo' }); await client.requestResource('https://op.example.com/foo'); expect(handler.firstCall.thisValue.req.headers['user-agent']).to.equal( 'foo' @@ -287,7 +289,7 @@ describe('client initialization', function () { it('should pass agent argument', async function () { const handler = sinon.stub().returns([200]); nock('https://op.example.com').get('/foo').reply(handler); - const client = await getClient({ ...config }); + const { client } = await getClient({ ...config }); expect(client[custom.http_options]({}).agent.https).to.eq(agent); }); }); @@ -346,7 +348,7 @@ describe('client initialization', function () { it('should set default client signing assertion alg', async function () { const handler = sinon.stub().returns([200, {}]); nock('https://op.example.com').post('/oauth/token').reply(handler); - const client = await getClient(getConfig(config)); + const { client } = await getClient(getConfig(config)); await client.grant(); const [, body] = handler.firstCall.args; const jwt = new URLSearchParams(body).get('client_assertion'); @@ -359,7 +361,7 @@ describe('client initialization', function () { it('should set custom client signing assertion alg', async function () { const handler = sinon.stub().returns([200, {}]); nock('https://op.example.com').post('/oauth/token').reply(handler); - const client = await getClient({ + const { client } = await getClient({ ...getConfig(config), clientAssertionSigningAlg: 'RS384', }); @@ -394,7 +396,7 @@ describe('client initialization', function () { .get('/.well-known/openid-configuration') .reply(200, spy); - const client = await getClient(config); + const { client } = await getClient(config); await getClient(config); await getClient(config); expect(client.client_id).to.eq('__test_cache_max_age_client_id__'); @@ -423,7 +425,7 @@ describe('client initialization', function () { .get('/.well-known/openid-configuration') .reply(200, spy); - const client = await getClient(config); + const { client } = await getClient(config); await getClient({ ...config }); await getClient({ ...config }); expect(client.client_id).to.eq('__test_cache_max_age_client_id__'); @@ -442,7 +444,7 @@ describe('client initialization', function () { .get('/.well-known/openid-configuration') .reply(200, spy); - const client = await getClient(config); + const { client } = await getClient(config); clock.tick(10 * mins + 1); await getClient(config); clock.tick(1 * mins); @@ -465,7 +467,7 @@ describe('client initialization', function () { .reply(200, spy); config = { ...config, discoveryCacheMaxAge: 20 * mins }; - const client = await getClient(config); + const { client } = await getClient(config); clock.tick(10 * mins + 1); await getClient(config); expect(spy.callCount).to.eq(1); @@ -489,7 +491,7 @@ describe('client initialization', function () { await assert.isRejected(getClient(config)); - const client = await getClient(config); + const { client } = await getClient(config); expect(client.client_id).to.eq('__test_cache_max_age_client_id__'); expect(spy.callCount).to.eq(1); }); @@ -509,7 +511,7 @@ describe('client initialization', function () { assert.isRejected(getClient(config)), assert.isRejected(getClient(config)), ]); - const client = await getClient(config); + const { client } = await getClient(config); expect(client.client_id).to.eq('__test_cache_max_age_client_id__'); expect(spy.callCount).to.eq(1); }); diff --git a/test/config.tests.js b/test/config.tests.js index beb63559..70db0112 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -857,4 +857,43 @@ describe('get config', () => { }); } }); + + it('should require a session store for back-channel logout', () => { + assert.throws( + () => getConfig({ ...defaultConfig, backchannelLogout: true }), + TypeError, + `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions) or custom hooks for "isLoggedOut" and "onLogoutToken".` + ); + }); + + it(`should configure back-channel logout with it's own store`, () => { + assert.doesNotThrow(() => + getConfig({ + ...defaultConfig, + backchannelLogout: { store: {} }, + }) + ); + }); + + it(`should configure back-channel logout with a shared store`, () => { + assert.doesNotThrow(() => + getConfig({ + ...defaultConfig, + backchannelLogout: true, + session: { store: {} }, + }) + ); + }); + + it(`should configure back-channel logout with custom hooks`, () => { + assert.doesNotThrow(() => + getConfig({ + ...defaultConfig, + backchannelLogout: { + isLoggedOut: () => {}, + onLogoutToken: () => {}, + }, + }) + ); + }); }); diff --git a/test/fixture/cert.js b/test/fixture/cert.js index d5c3626c..cc3e0ece 100644 --- a/test/fixture/cert.js +++ b/test/fixture/cert.js @@ -1,21 +1,15 @@ const { JWK, JWKS, JWT } = require('jose'); +const crypto = require('crypto'); const key = JWK.asKey({ e: 'AQAB', - n: - 'wQrThQ9HKf8ksCQEzqOu0ofF8DtLJgexeFSQBNnMQetACzt4TbHPpjhTWUIlD8bFCkyx88d2_QV3TewMtfS649Pn5hV6adeYW2TxweAA8HVJxskcqTSa_ktojQ-cD43HIStsbqJhHoFv0UY6z5pwJrVPT-yt38ciKo9Oc9IhEl6TSw-zAnuNW0zPOhKjuiIqpAk1lT3e6cYv83ahx82vpx3ZnV83dT9uRbIbcgIpK4W64YnYb5uDH7hGI8-4GnalZDfdApTu-9Y8lg_1v5ul-eQDsLCkUCPkqBaNiCG3gfZUAKp9rrFRE_cJTv_MJn-y_XSTMWILvTY7vdSMRMo4kQ', - d: - 'EMHY1K8b1VhxndyykiGBVoM0uoLbJiT60eA9VD53za0XNSJncg8iYGJ5UcE9KF5v0lIQDIJfIN2tmpUIEW96HbbSZZWtt6xgbGaZ2eOREU6NJfVlSIbpgXOYUs5tFKiRBZ8YXY448gX4Z-k5x7W3UJTimqSH_2nw3FLuU32FI2vtf4ToUKEcoUdrIqoAwZ1et19E7Q_NCG2y1nez0LpD8PKgfeX1OVHdQm7434-9FS-R_eMcxqZ6mqZO2QDuign8SPHTR-KooAe8B-0MpZb7QF3YtMSQk8RlrMUcAYwv8R8dvFergCjauH0hOHvtKPq6Smj0VuimelEUZfp94r3pBQ', - p: - '9i2D_PLFPnFfztYccTGxzgiXezRpMsXD2Z9PA7uxw0sXnkV1TjZkSc3V_59RxyiTtvYlNCbGYShds__ogXouuYqbWaC43_zj3eGqAWL3i5C-k1u4S3ekgKn8AkGjlqCObuyLRsPvDfBkv1wo2tfIAEoNg_sHYIIRkTq68g58if8', - q: - 'yL6UUD_MB_pCHwf6LvNC2k0lfHHOxfW3lOo_XTqt9dg9yTO21OS4BF7Uce1kFJJIfuGrK6cMmusHKkSsJm1_khR3G9owokrBDFOZ_iSWvt3qIG5K3CNgl1_C8NqTeyKEVziCCiaL9CZpwfqHIVNnDCchGNkpVRqsfHmzPEnXnW8', - dp: - 'rFf3FEn9rpZ-pXYeGVzaBszbCAUMNOBhGWS_U3S-oWNb2JD169iGY2j4DWpDPTN6Hle6egU_UtuIpjBdXO_l8D1KPvgXFbCc8kQ-2ZOojAu8b7uBjUvoXa8jX40Gcrhanut5IgSfwlluns1tSLBSM2mkhqZiZr0IgWzlXfqoU48', - dq: - 'kihQC-2nO9e19Kn2OeDbt92bgXPLPM6ej0nOQK7MocaDlc6VO4QbhvMUcq6Iw4GOTvM3kVzbDKA6Y0gEnyXyUAWegyTlbARJchQcdrFlICqqoFotHwKS_SO352z9HBYRjP-TjphqJaUiMx2Y7WawDGUg79qNAW2eUDK7kRWiavk', - qi: - '8hAW25CmPjLAXpzkMpXpXsvJKdgql0Zjt-OeSVwzQN5dLYmu-Q98Xl5n8H-Nfr8aOmPfHBQ8M9FOMpxbgg8gbqixpkrxcTIGjpuH8RFYXj_0TYSBkCSOoc7tAP7YjOUOGJMqFHDYZVD-gmsCuRwWx3jKFxRrWLS5b8kWzkON0bM', + n: 'wQrThQ9HKf8ksCQEzqOu0ofF8DtLJgexeFSQBNnMQetACzt4TbHPpjhTWUIlD8bFCkyx88d2_QV3TewMtfS649Pn5hV6adeYW2TxweAA8HVJxskcqTSa_ktojQ-cD43HIStsbqJhHoFv0UY6z5pwJrVPT-yt38ciKo9Oc9IhEl6TSw-zAnuNW0zPOhKjuiIqpAk1lT3e6cYv83ahx82vpx3ZnV83dT9uRbIbcgIpK4W64YnYb5uDH7hGI8-4GnalZDfdApTu-9Y8lg_1v5ul-eQDsLCkUCPkqBaNiCG3gfZUAKp9rrFRE_cJTv_MJn-y_XSTMWILvTY7vdSMRMo4kQ', + d: 'EMHY1K8b1VhxndyykiGBVoM0uoLbJiT60eA9VD53za0XNSJncg8iYGJ5UcE9KF5v0lIQDIJfIN2tmpUIEW96HbbSZZWtt6xgbGaZ2eOREU6NJfVlSIbpgXOYUs5tFKiRBZ8YXY448gX4Z-k5x7W3UJTimqSH_2nw3FLuU32FI2vtf4ToUKEcoUdrIqoAwZ1et19E7Q_NCG2y1nez0LpD8PKgfeX1OVHdQm7434-9FS-R_eMcxqZ6mqZO2QDuign8SPHTR-KooAe8B-0MpZb7QF3YtMSQk8RlrMUcAYwv8R8dvFergCjauH0hOHvtKPq6Smj0VuimelEUZfp94r3pBQ', + p: '9i2D_PLFPnFfztYccTGxzgiXezRpMsXD2Z9PA7uxw0sXnkV1TjZkSc3V_59RxyiTtvYlNCbGYShds__ogXouuYqbWaC43_zj3eGqAWL3i5C-k1u4S3ekgKn8AkGjlqCObuyLRsPvDfBkv1wo2tfIAEoNg_sHYIIRkTq68g58if8', + q: 'yL6UUD_MB_pCHwf6LvNC2k0lfHHOxfW3lOo_XTqt9dg9yTO21OS4BF7Uce1kFJJIfuGrK6cMmusHKkSsJm1_khR3G9owokrBDFOZ_iSWvt3qIG5K3CNgl1_C8NqTeyKEVziCCiaL9CZpwfqHIVNnDCchGNkpVRqsfHmzPEnXnW8', + dp: 'rFf3FEn9rpZ-pXYeGVzaBszbCAUMNOBhGWS_U3S-oWNb2JD169iGY2j4DWpDPTN6Hle6egU_UtuIpjBdXO_l8D1KPvgXFbCc8kQ-2ZOojAu8b7uBjUvoXa8jX40Gcrhanut5IgSfwlluns1tSLBSM2mkhqZiZr0IgWzlXfqoU48', + dq: 'kihQC-2nO9e19Kn2OeDbt92bgXPLPM6ej0nOQK7MocaDlc6VO4QbhvMUcq6Iw4GOTvM3kVzbDKA6Y0gEnyXyUAWegyTlbARJchQcdrFlICqqoFotHwKS_SO352z9HBYRjP-TjphqJaUiMx2Y7WawDGUg79qNAW2eUDK7kRWiavk', + qi: '8hAW25CmPjLAXpzkMpXpXsvJKdgql0Zjt-OeSVwzQN5dLYmu-Q98Xl5n8H-Nfr8aOmPfHBQ8M9FOMpxbgg8gbqixpkrxcTIGjpuH8RFYXj_0TYSBkCSOoc7tAP7YjOUOGJMqFHDYZVD-gmsCuRwWx3jKFxRrWLS5b8kWzkON0bM', kty: 'RSA', use: 'sig', alg: 'RS256', @@ -45,3 +39,25 @@ module.exports.makeIdToken = (payload) => { header: { kid: key.kid }, }); }; + +module.exports.makeLogoutToken = ({ payload, sid, sub, secret } = {}) => { + return JWT.sign( + { + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + ...(sid && { sid }), + ...(sub && { sub }), + }, + secret || key.toPEM(true), + { + issuer: 'https://op.example.com/', + audience: '__test_client_id__', + iat: true, + jti: crypto.randomBytes(16).toString('hex'), + algorithm: secret ? 'HS256' : 'RS256', + header: { typ: 'logout+jwt' }, + ...payload, + } + ); +}; diff --git a/test/fixture/store.js b/test/fixture/store.js new file mode 100644 index 00000000..7e7d5369 --- /dev/null +++ b/test/fixture/store.js @@ -0,0 +1,17 @@ +const { promisify } = require('util'); +const redis = require('redis-mock'); +const RedisStore = require('connect-redis')({ Store: class Store {} }); + +module.exports = () => { + const client = redis.createClient(); + const store = new RedisStore({ client: client, prefix: '' }); + client.asyncSet = promisify(client.set).bind(client); + const get = promisify(client.get).bind(client); + client.asyncGet = async (id) => { + const val = await get(id); + return val ? JSON.parse(val) : val; + }; + client.asyncDbsize = promisify(client.dbsize).bind(client); + client.asyncTtl = promisify(client.ttl).bind(client); + return { client, store }; +}; diff --git a/test/login.tests.js b/test/login.tests.js index 255804a2..44655bc2 100644 --- a/test/login.tests.js +++ b/test/login.tests.js @@ -354,7 +354,6 @@ describe('auth', () => { baseUrl, followRedirect: false, }); - console.log(res); assert.equal(res.statusCode, 302); const parsed = url.parse(res.headers.location, true); @@ -596,7 +595,6 @@ describe('auth', () => { json: true, }); assert.equal(res.statusCode, 500); - console.log(res.body.err.message); assert.match( res.body.err.message, /^Issuer.discover\(\) failed/, diff --git a/test/logout.tests.js b/test/logout.tests.js index f7d7b87a..21bfe36e 100644 --- a/test/logout.tests.js +++ b/test/logout.tests.js @@ -343,7 +343,6 @@ describe('logout route', async () => { json: true, }); assert.equal(res.statusCode, 500); - console.log(res.body.err.message); assert.match( res.body.err.message, /^Issuer.discover\(\) failed/,