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/,