Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC Back-Channel Logout #484

Merged
merged 2 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>');
}
});
Comment on lines +54 to +60

Check failure

Code scanning / CodeQL

Missing rate limiting

This route handler performs [authorization](1), but is not rate-limited.

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

module.exports = app;
Loading