Skip to content

Commit

Permalink
OIDC Back-Channel Logout (#484)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamjmcgrath authored Sep 15, 2023
1 parent 37f8082 commit 864fb04
Show file tree
Hide file tree
Showing 31 changed files with 1,211 additions and 108 deletions.
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 Identity Provider.
- 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)
103 changes: 103 additions & 0 deletions end-to-end/backchannel-logout.test.js
Original file line number Diff line number Diff line change
@@ -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'));
});
29 changes: 29 additions & 0 deletions end-to-end/fixture/helpers.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(`
<pre style="border: 1px solid #ccc; padding: 10px; white-space: break-spaces; background: whitesmoke;">curl -X POST http://localhost:3000/backchannel-logout -d "logout_token=${logoutToken}"</pre>
`);
};

module.exports = {
baseUrl,
start,
Expand All @@ -100,4 +128,5 @@ module.exports = {
goto,
login,
logout,
logoutTokenTester,
};
12 changes: 12 additions & 0 deletions end-to-end/fixture/jwk.js
Original file line number Diff line number Diff line change
@@ -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();
36 changes: 22 additions & 14 deletions end-to-end/fixture/oidc-provider.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
const Provider = require('oidc-provider');
const { privateJWK, publicJWK } = require('./jwk');

const client = {
client_id: 'test-express-openid-connect-client-id',
client_secret: 'test-express-openid-connect-client-secret',
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 = {
Expand All @@ -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',
},
Expand All @@ -47,6 +50,11 @@ const config = {
claims: () => ({ sub: id }),
};
},
features: {
backchannelLogout: {
enabled: true,
},
},
};

const PORT = process.env.PROVIDER_PORT || 3001;
Expand Down
69 changes: 69 additions & 0 deletions examples/backchannel-logout-custom-genid.js
Original file line number Diff line number Diff line change
@@ -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} <a href="/logout">logout</a>`);
} else {
res.send('<a href="/login">login</a>');
}
});

// For testing purposes only
app.get(
'/logout-token',
requiresAuth(),
logoutTokenTester('backchannel-logout-client', true)
);

module.exports = app;
Loading

0 comments on commit 864fb04

Please sign in to comment.