From 8b2ff7e9f33206b99c878658b4cf9319fdf3eb85 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 24 Nov 2023 16:16:08 +0000 Subject: [PATCH 1/8] a0 session layer --- src/auth0-session/client/abstract-client.ts | 6 ++ src/auth0-session/client/edge-client.ts | 5 + src/auth0-session/client/node-client.ts | 8 +- src/auth0-session/config.ts | 24 +++++ src/auth0-session/get-config.ts | 21 +++- .../handlers/backchannel-logout.ts | 53 ++++++++++ src/auth0-session/http/auth0-response.ts | 4 + src/auth0-session/http/node-response.ts | 9 ++ .../utils/logout-token-verifier.ts | 70 +++++++++++++ src/http/auth0-next-response.ts | 8 ++ tests/auth0-session/config.test.ts | 31 ++++++ tests/auth0-session/fixtures/cert.ts | 20 ++++ tests/auth0-session/fixtures/server.ts | 5 + .../handlers/backchannel-logout.test.ts | 93 ++++++++++++++++++ .../utils/logout-token-verifier.test.ts | 97 +++++++++++++++++++ 15 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 src/auth0-session/handlers/backchannel-logout.ts create mode 100644 src/auth0-session/utils/logout-token-verifier.ts create mode 100644 tests/auth0-session/handlers/backchannel-logout.test.ts create mode 100644 tests/auth0-session/utils/logout-token-verifier.test.ts diff --git a/src/auth0-session/client/abstract-client.ts b/src/auth0-session/client/abstract-client.ts index 54ca85f46..e4eeb4f72 100644 --- a/src/auth0-session/client/abstract-client.ts +++ b/src/auth0-session/client/abstract-client.ts @@ -84,6 +84,11 @@ export interface AuthorizationParameters { [key: string]: unknown; } +export type IssuerMetadata = { + issuer: string; + jwks_uri?: string; +}; + export abstract class AbstractClient { constructor(protected config: Config, protected telemetry: Telemetry) {} abstract authorizationUrl(parameters: Record): Promise; @@ -103,6 +108,7 @@ export abstract class AbstractClient { abstract generateRandomCodeVerifier(): string; abstract generateRandomNonce(): string; abstract calculateCodeChallenge(codeVerifier: string): Promise | string; + abstract getIssuerMetadata(): Promise; } export type GetClient = (config: Config) => Promise; diff --git a/src/auth0-session/client/edge-client.ts b/src/auth0-session/client/edge-client.ts index d8acd6a8f..354eda529 100644 --- a/src/auth0-session/client/edge-client.ts +++ b/src/auth0-session/client/edge-client.ts @@ -242,6 +242,11 @@ export class EdgeClient extends AbstractClient { calculateCodeChallenge(codeVerifier: string): Promise { return oauth.calculatePKCECodeChallenge(codeVerifier); } + + async getIssuerMetadata(): Promise { + const [as] = await this.getClient(); + return as; + } } export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise) => { diff --git a/src/auth0-session/client/node-client.ts b/src/auth0-session/client/node-client.ts index e4fa34c58..0ea9cdbf2 100644 --- a/src/auth0-session/client/node-client.ts +++ b/src/auth0-session/client/node-client.ts @@ -15,7 +15,8 @@ import { EndSessionParameters, errors, generators, - Issuer + Issuer, + IssuerMetadata } from 'openid-client'; import { ApplicationError, DiscoveryError, EscapedError, IdentityProviderError, UserInfoError } from '../utils/errors'; import { createPrivateKey } from 'crypto'; @@ -236,6 +237,11 @@ export class NodeClient extends AbstractClient { calculateCodeChallenge(codeVerifier: string): string { return generators.codeChallenge(codeVerifier); } + + async getIssuerMetadata(): Promise { + const { issuer } = await this.getClient(); + return issuer.metadata; + } } export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise) => { diff --git a/src/auth0-session/config.ts b/src/auth0-session/config.ts index 91c2b2644..27c2ce8e2 100644 --- a/src/auth0-session/config.ts +++ b/src/auth0-session/config.ts @@ -204,6 +204,30 @@ export interface Config { * cookie (Setting SameSite=Strict for example). */ transactionCookie: Omit & { name: string }; + + /** + * Set to `true` to enable Back-Channel Logout in your application. + * + * On receipt of a Logout Token the backchannelLogout 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 BackchannelLogoutOptions.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; +} + +export interface BackchannelLogoutOptions { + /** + * Used to store Back-Channel Logout entries, you can specify a separate store + * for this or just reuse {@link SessionConfig.store} if you are using one already. + * + * The store should have `get`, `set` and `destroy` methods. + */ + store: SessionStore; } /** diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts index 84c1cab6a..1c01c8c75 100644 --- a/src/auth0-session/get-config.ts +++ b/src/auth0-session/get-config.ts @@ -30,7 +30,18 @@ const paramsSchema = Joi.object({ .default(7 * 24 * 60 * 60), // 7 days, autoSave: Joi.boolean().optional().default(true), name: Joi.string().token().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.object().required().messages({ + // eslint-disable-next-line max-len + 'any.required': `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store if you have stateful sessions).` + }) + }) + }), genId: Joi.function().maxArity(2).when(Joi.ref('store'), { then: Joi.required() }), storeIDToken: Joi.boolean().optional().default(true), cookie: Joi.object({ @@ -176,7 +187,13 @@ const paramsSchema = Joi.object({ path: Joi.string().uri({ relativeOnly: true }).default(Joi.ref('/session.cookie.transient')) }) .default() - .unknown(false) + .unknown(false), + backchannelLogout: Joi.alternatives([ + Joi.object({ + store: Joi.object().optional() + }), + Joi.boolean() + ]).default(false) }); export type DeepPartial = { diff --git a/src/auth0-session/handlers/backchannel-logout.ts b/src/auth0-session/handlers/backchannel-logout.ts new file mode 100644 index 000000000..141637fcb --- /dev/null +++ b/src/auth0-session/handlers/backchannel-logout.ts @@ -0,0 +1,53 @@ +import { Auth0Request, Auth0Response } from '../http'; +import { GetConfig } from '../config'; +import { GetClient } from '../client/abstract-client'; +import getLogoutTokenVerifier from '../utils/logout-token-verifier'; +import * as querystring from 'querystring'; + +export type HandleBackchannelLogout = (req: Auth0Request, res: Auth0Response) => Promise; + +export default function backchannelLogoutHandlerFactory( + getConfig: GetConfig, + getClient: GetClient +): HandleBackchannelLogout { + const getConfigFn = typeof getConfig === 'function' ? getConfig : () => getConfig; + const verifyLogoutToken = getLogoutTokenVerifier(); + return async (req, res) => { + const config = await getConfigFn(req); + const client = await getClient(config); + res.setHeader('cache-control', 'no-store'); + let body = await req.getBody(); + if (typeof body === 'string') { + try { + body = querystring.parse(body) as Record; + } catch (e) { + body = {}; + } + } + const logoutToken = (body as Record).logout_token; + if (!logoutToken) { + throw new Error('Missing Logout Token'); + } + const token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata()); + const { + session: { absoluteDuration, rolling: rollingEnabled, rollingDuration, store }, + backchannelLogout + } = config; + const backchannelLogoutStore = typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store; + const maxAge = + (rollingEnabled + ? Math.min(absoluteDuration as number, rollingDuration as number) + : (absoluteDuration as number)) * 1000; + const now = (Date.now() / 1000) | 0; + const payload = { + header: { iat: now, uat: now, exp: now + maxAge, maxAge }, + data: {} + }; + const { iss, sid, sub } = token; + await Promise.all([ + sid && backchannelLogoutStore.set(`${iss}|${sid}`, payload), + sub && backchannelLogoutStore.set(`${iss}|${sub}`, payload) + ]); + res.send204(); + }; +} diff --git a/src/auth0-session/http/auth0-response.ts b/src/auth0-session/http/auth0-response.ts index 87d0565be..1f3991447 100644 --- a/src/auth0-session/http/auth0-response.ts +++ b/src/auth0-session/http/auth0-response.ts @@ -6,4 +6,8 @@ export default abstract class Auth0Response extends Auth0ResponseCook } public abstract redirect(location: string, status?: number): void; + + public abstract send204(): void; + + public abstract setHeader(name: string, value: string): void; } diff --git a/src/auth0-session/http/node-response.ts b/src/auth0-session/http/node-response.ts index 733866064..0f36f0ec8 100644 --- a/src/auth0-session/http/node-response.ts +++ b/src/auth0-session/http/node-response.ts @@ -30,4 +30,13 @@ export default class NodeResponse ext }); this.res.end(htmlSafe(location)); } + + public send204(): void { + this.res.statusCode = 204; + this.res.end(); + } + + public setHeader(name: string, value: string): void { + this.res.setHeader(name, value); + } } diff --git a/src/auth0-session/utils/logout-token-verifier.ts b/src/auth0-session/utils/logout-token-verifier.ts new file mode 100644 index 000000000..3d6f138a3 --- /dev/null +++ b/src/auth0-session/utils/logout-token-verifier.ts @@ -0,0 +1,70 @@ +import { + createRemoteJWKSet, + FlattenedJWSInput, + GetKeyFunction, + JWSHeaderParameters, + jwtVerify, + JWTPayload +} from 'jose'; +import { Config } from '../config'; +import { IssuerMetadata } from '../client/abstract-client'; + +type GetKeyFn = GetKeyFunction; + +const isObject = (a: any) => !!a && a.constructor === Object; + +export type VerifyLogoutToken = ( + logoutToken: string, + config: Config, + issuerMetadata: IssuerMetadata +) => Promise; + +export default function getLogoutTokenVerifier(): VerifyLogoutToken { + let remoteJwkSet: GetKeyFn; + + return async (logoutToken: string, config: Config, issuerMetadata: IssuerMetadata) => { + let keyInput: Uint8Array | GetKeyFn; + if (config.idTokenSigningAlg === 'RS256') { + if (!remoteJwkSet) { + remoteJwkSet = createRemoteJWKSet(new URL(issuerMetadata.jwks_uri!)); + } + keyInput = remoteJwkSet; + } else { + keyInput = new TextEncoder().encode(config.clientSecret as string); + } + const { payload } = await jwtVerify(logoutToken, keyInput as Uint8Array, { + issuer: issuerMetadata.issuer, + audience: config.clientID, + algorithms: [config.idTokenSigningAlg], + requiredClaims: ['iat'] + }); + + if (!('sid' in payload) && !('sub' in payload)) { + throw new Error('either "sid" or "sub" (or both) claims must be present'); + } + + if ('nonce' in payload) { + throw new Error('"nonce" claim is prohibited'); + } + + if (!('events' in payload)) { + throw new Error('"events" claim is missing'); + } + + if (!isObject(payload.events)) { + throw new Error('"events" claim must be an object'); + } + + if (!('http://schemas.openid.net/event/backchannel-logout' in (payload as { events?: any }).events)) { + throw new Error('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim'); + } + + if (!isObject((payload as { events?: any }).events['http://schemas.openid.net/event/backchannel-logout'])) { + throw new Error( + '"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object' + ); + } + + return payload; + }; +} diff --git a/src/http/auth0-next-response.ts b/src/http/auth0-next-response.ts index b7a9525d4..ddfec63c5 100644 --- a/src/http/auth0-next-response.ts +++ b/src/http/auth0-next-response.ts @@ -27,4 +27,12 @@ export default class Auth0NextResponse extends Auth0Response { this.res.cookies.set(cookie); } } + + public setHeader(name: string, value: string) { + this.res.headers.set(name, value); + } + + public send204() { + this.res = new NextResponse(null, { status: 204 }); + } } diff --git a/tests/auth0-session/config.test.ts b/tests/auth0-session/config.test.ts index 252018eef..872414451 100644 --- a/tests/auth0-session/config.test.ts +++ b/tests/auth0-session/config.test.ts @@ -545,4 +545,35 @@ describe('Config', () => { ).not.toThrowError(); expect(() => config({ response_type: 'code id_token', response_mode: 'form_post' })).not.toThrow(); }); + + it('should require a session store for back-channel logout', () => { + expect(() => getConfig({ ...defaultConfig, backchannelLogout: true })).toThrow( + // eslint-disable-next-line max-len + `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store if you have stateful sessions).` + ); + }); + + it(`should configure back-channel logout with it's own store`, () => { + expect(() => + getConfig({ + ...defaultConfig, + backchannelLogout: { store: {} } + }) + ).not.toThrow(); + }); + + it(`should configure back-channel logout with a shared store`, () => { + expect(() => + getConfig({ + ...defaultConfig, + backchannelLogout: true, + session: { + store: {}, + genId() { + return ''; + } + } + }) + ).not.toThrow(); + }); }); diff --git a/tests/auth0-session/fixtures/cert.ts b/tests/auth0-session/fixtures/cert.ts index 0fd225698..da59043e7 100644 --- a/tests/auth0-session/fixtures/cert.ts +++ b/tests/auth0-session/fixtures/cert.ts @@ -1,4 +1,5 @@ import * as jose from 'jose'; +import crypto from 'crypto'; import { IdTokenClaims } from 'openid-client'; const publicKey = { @@ -57,3 +58,22 @@ export const makeIdToken = async (payload?: Partial): Promise { + payload = { + iss: 'https://op.example.com/', + aud: '__test_client_id__', + iat: Math.round(Date.now() / 1000), + jti: crypto.randomBytes(16).toString('hex'), + events: { + 'http://schemas.openid.net/event/backchannel-logout': {} + }, + ...payload + }; + + const symmetricKey = (secret && new TextEncoder().encode(secret)) || null; + + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: symmetricKey ? 'HS256' : 'RS256', typ: 'logout+jwt' }) + .sign(symmetricKey || (await jose.importJWK(privateKey))); +}; diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts index a5c228430..e6b96269a 100644 --- a/tests/auth0-session/fixtures/server.ts +++ b/tests/auth0-session/fixtures/server.ts @@ -26,6 +26,7 @@ import { Claims } from '../../../src/session'; import version from '../../../src/version'; import { NodeRequest, NodeResponse } from '../../../src/auth0-session/http'; import { clientGetter } from '../../../src/auth0-session/client/node-client'; +import backchannelLogoutHandlerFactory from '../../../src/auth0-session/handlers/backchannel-logout'; export type SessionResponse = TokenSetParameters & { claims: Claims }; @@ -67,6 +68,7 @@ type Handlers = { handleLogin: (req: NodeRequest, res: NodeResponse, opts?: LoginOptions) => Promise; handleLogout: (req: NodeRequest, res: NodeResponse, opts?: LogoutOptions) => Promise; handleCallback: (req: NodeRequest, res: NodeResponse, opts?: CallbackOptions) => Promise; + handleBackchannelLogout: (req: NodeRequest, res: NodeResponse) => Promise; handleSession: (req: IncomingMessage, res: ServerResponse) => Promise; }; @@ -81,6 +83,7 @@ const createHandlers = (params: ConfigParameters): Handlers => { handleLogin: loginHandler(config, getClient, transientStore), handleLogout: logoutHandler(config, getClient, sessionCache), handleCallback: callbackHandler(config, getClient, sessionCache, transientStore), + handleBackchannelLogout: backchannelLogoutHandlerFactory(config, getClient), handleSession: async (req: IncomingMessage, res: ServerResponse) => { const nodeReq = new NodeRequest(req); const [json, iat] = await cookieStore.read(nodeReq); @@ -146,6 +149,8 @@ const requestListener = nodeRes, (callbackOptions || nodeCallbackOptions) as CallbackOptions ); + case '/backchannel-logout': + return await handlers.handleBackchannelLogout(nodeReq, nodeRes); case '/session': return await handlers.handleSession(req, res); default: diff --git a/tests/auth0-session/handlers/backchannel-logout.test.ts b/tests/auth0-session/handlers/backchannel-logout.test.ts new file mode 100644 index 000000000..8abdee02f --- /dev/null +++ b/tests/auth0-session/handlers/backchannel-logout.test.ts @@ -0,0 +1,93 @@ +import { setup, teardown } from '../fixtures/server'; +import { defaultConfig, post } from '../fixtures/helpers'; +import { makeLogoutToken } from '../fixtures/cert'; + +class Store { + public store: { [key: string]: any }; + constructor() { + this.store = {}; + } + get(id: string) { + return Promise.resolve(this.store[id]); + } + async set(id: string, val: any) { + this.store[id] = val; + await Promise.resolve(); + } + async delete(id: string) { + delete this.store[id]; + await Promise.resolve(); + } +} + +describe('backchannel-logout', () => { + afterEach(teardown); + + it('should fail when logout_token is missing', async () => { + const baseURL = await setup({ ...defaultConfig, session: { store: new Store(), genId: () => 'foo' } }); + await expect(post(baseURL, '/backchannel-logout', { body: {} })).rejects.toThrow('Missing Logout Token'); + }); + + it('should succeed with valid logout token', async () => { + const store = new Store(); + const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const { res } = await post(baseURL, '/backchannel-logout', { + body: { logout_token: await makeLogoutToken({ sid: 'foo' }) }, + fullResponse: true + }); + await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + header: { + maxAge: 24 * 60 * 60 * 1000, + exp: expect.any(Number) + }, + data: {} + }); + expect(res.statusCode).toEqual(204); + }); + + it('should save sid and sub', async () => { + const store = new Store(); + const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const { res } = await post(baseURL, '/backchannel-logout', { + body: { logout_token: await makeLogoutToken({ sid: 'foo', sub: 'bar' }) }, + fullResponse: true + }); + await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + data: {} + }); + await expect(store.get('https://op.example.com/|bar')).resolves.toMatchObject({ + data: {} + }); + expect(res.statusCode).toEqual(204); + }); + + it('should save just sub', async () => { + const store = new Store(); + const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const { res } = await post(baseURL, '/backchannel-logout', { + body: { logout_token: await makeLogoutToken({ sub: 'bar' }) }, + fullResponse: true + }); + await expect(store.get('https://op.example.com/|bar')).resolves.toMatchObject({ + data: {} + }); + expect(res.statusCode).toEqual(204); + }); + + it('should save logout with absolute duration', async () => { + const store = new Store(); + const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo', rolling: false } }); + const { res } = await post(baseURL, '/backchannel-logout', { + body: { logout_token: await makeLogoutToken({ sid: 'foo' }) }, + fullResponse: true + }); + await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + header: { + maxAge: 7 * 24 * 60 * 60 * 1000, + exp: expect.any(Number) + }, + data: {} + }); + expect(res.statusCode).toEqual(204); + }); +}); diff --git a/tests/auth0-session/utils/logout-token-verifier.test.ts b/tests/auth0-session/utils/logout-token-verifier.test.ts new file mode 100644 index 000000000..6921ecd5d --- /dev/null +++ b/tests/auth0-session/utils/logout-token-verifier.test.ts @@ -0,0 +1,97 @@ +import { makeLogoutToken, jwks } from '../fixtures/cert'; +import { getConfig } from '../../../src/auth0-session'; +import getLogoutTokenVerifier, { VerifyLogoutToken } from '../../../src/auth0-session/utils/logout-token-verifier'; +import { withApi } from '../../fixtures/default-settings'; +import nock from 'nock'; + +const metadata = { issuer: 'https://op.example.com/', jwks_uri: 'https://op.example.com/.well-known/jwks.json' }; + +describe('logoutTokenVerifier', () => { + let verify: VerifyLogoutToken; + let jwksSpy: jest.SpyInstance; + + beforeEach(() => { + jwksSpy = jest.fn().mockReturnValue(jwks); + verify = getLogoutTokenVerifier(); + nock('https://op.example.com').get('/.well-known/jwks.json').reply(200, jwksSpy); + }); + + afterEach(() => { + nock.cleanAll(); + jwksSpy.mockReset(); + }); + + it('should verify a valid logout token', async () => { + const token = await makeLogoutToken({ sid: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).resolves.toMatchObject({ sid: 'foo' }); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should cache the jwks', async () => { + const token = await makeLogoutToken({ sid: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).resolves.not.toThrow(); + await expect(verify(token, getConfig(withApi), metadata)).resolves.not.toThrow(); + expect(jwksSpy).toHaveBeenCalledTimes(1); + }); + + it('should verify a logout token signed with HS256', async () => { + const token = await makeLogoutToken({ sid: 'foo' }, 'foobarbaz'); + await expect( + verify(token, getConfig({ ...withApi, clientSecret: 'foobarbaz', idTokenSigningAlg: 'HS256' }), metadata) + ).resolves.not.toThrow(); + expect(jwksSpy).not.toHaveBeenCalled(); + }); + + it('should verify a valid logout token with just a sub', async () => { + const token = await makeLogoutToken({ sub: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).resolves.toMatchObject({ sub: 'foo' }); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when no sid or sub', async () => { + const token = await makeLogoutToken(); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow( + 'either "sid" or "sub" (or both) claims must be present' + ); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when nonce is in payload', async () => { + const token = await makeLogoutToken({ nonce: 'foo', sid: 'bar' }); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow('"nonce" claim is prohibited'); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when events not in payload', async () => { + const token = await makeLogoutToken({ events: undefined, sid: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow('"events" claim is missing'); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when events not an object', async () => { + const token = await makeLogoutToken({ events: [], sid: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow('"events" claim must be an object'); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when events missing backchannel-logout', async () => { + const token = await makeLogoutToken({ events: {}, sid: 'foo' }); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow( + '"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim' + ); + expect(jwksSpy).toHaveBeenCalled(); + }); + + it('should fail when events missing backchannel-logout', async () => { + const token = await makeLogoutToken({ + events: { + 'http://schemas.openid.net/event/backchannel-logout': '' + }, + sid: 'foo' + }); + await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow( + '"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object' + ); + expect(jwksSpy).toHaveBeenCalled(); + }); +}); From e4402bb49ef89693ab5803b385d0c8e74658487f Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Mon, 27 Nov 2023 11:51:24 +0000 Subject: [PATCH 2/8] Add isLoggedOut helper --- src/auth0-session/get-config.ts | 2 +- .../handlers/backchannel-logout.ts | 26 +++++++++++++--- src/auth0-session/index.ts | 6 ++++ tests/auth0-session/config.test.ts | 2 +- .../handlers/backchannel-logout.test.ts | 30 +++++++++++++------ 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/auth0-session/get-config.ts b/src/auth0-session/get-config.ts index 1c01c8c75..31446fdbc 100644 --- a/src/auth0-session/get-config.ts +++ b/src/auth0-session/get-config.ts @@ -38,7 +38,7 @@ const paramsSchema = Joi.object({ not: Joi.exist(), then: Joi.object().required().messages({ // eslint-disable-next-line max-len - 'any.required': `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store if you have stateful sessions).` + 'any.required': `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions).` }) }) }), diff --git a/src/auth0-session/handlers/backchannel-logout.ts b/src/auth0-session/handlers/backchannel-logout.ts index 141637fcb..76b8dadc3 100644 --- a/src/auth0-session/handlers/backchannel-logout.ts +++ b/src/auth0-session/handlers/backchannel-logout.ts @@ -1,5 +1,5 @@ import { Auth0Request, Auth0Response } from '../http'; -import { GetConfig } from '../config'; +import { Config, GetConfig } from '../config'; import { GetClient } from '../client/abstract-client'; import getLogoutTokenVerifier from '../utils/logout-token-verifier'; import * as querystring from 'querystring'; @@ -30,6 +30,7 @@ export default function backchannelLogoutHandlerFactory( } const token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata()); const { + clientID, session: { absoluteDuration, rolling: rollingEnabled, rollingDuration, store }, backchannelLogout } = config; @@ -43,11 +44,28 @@ export default function backchannelLogoutHandlerFactory( header: { iat: now, uat: now, exp: now + maxAge, maxAge }, data: {} }; - const { iss, sid, sub } = token; + const { sid, sub } = token; await Promise.all([ - sid && backchannelLogoutStore.set(`${iss}|${sid}`, payload), - sub && backchannelLogoutStore.set(`${iss}|${sub}`, payload) + sid && backchannelLogoutStore.set(`sid|${clientID}|${sid}`, payload), + sub && backchannelLogoutStore.set(`sub|${clientID}|${sub}`, payload) ]); res.send204(); }; } + +export type IsLoggedOut = (user: { [key: string]: any }, config: Config) => Promise; + +export const isLoggedOut: IsLoggedOut = async (user, config) => { + const { + clientID, + session: { store }, + backchannelLogout + } = config; + const backchannelLogoutStore = typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store; + const { sid, sub } = user; + const [logoutSid, logoutSub] = await Promise.all([ + backchannelLogoutStore.get(`sid|${clientID}|${sid}`), + backchannelLogoutStore.get(`sub|${clientID}|${sub}`) + ]); + return !!(logoutSid || logoutSub); +}; diff --git a/src/auth0-session/index.ts b/src/auth0-session/index.ts index a0378993b..bb50c6634 100644 --- a/src/auth0-session/index.ts +++ b/src/auth0-session/index.ts @@ -22,5 +22,11 @@ export { get as getConfig, ConfigParameters, DeepPartial } from './get-config'; export { default as loginHandler, HandleLogin } from './handlers/login'; export { default as logoutHandler, HandleLogout } from './handlers/logout'; export { default as callbackHandler, CallbackOptions, AfterCallback, HandleCallback } from './handlers/callback'; +export { + default as backchannelLogoutHandler, + HandleBackchannelLogout, + isLoggedOut, + IsLoggedOut +} from './handlers/backchannel-logout'; export { TokenEndpointResponse, AbstractClient, Telemetry } from './client/abstract-client'; export { SessionCache } from './session-cache'; diff --git a/tests/auth0-session/config.test.ts b/tests/auth0-session/config.test.ts index 872414451..bf78fa829 100644 --- a/tests/auth0-session/config.test.ts +++ b/tests/auth0-session/config.test.ts @@ -549,7 +549,7 @@ describe('Config', () => { it('should require a session store for back-channel logout', () => { expect(() => getConfig({ ...defaultConfig, backchannelLogout: true })).toThrow( // eslint-disable-next-line max-len - `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store if you have stateful sessions).` + `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions).` ); }); diff --git a/tests/auth0-session/handlers/backchannel-logout.test.ts b/tests/auth0-session/handlers/backchannel-logout.test.ts index 8abdee02f..77964e386 100644 --- a/tests/auth0-session/handlers/backchannel-logout.test.ts +++ b/tests/auth0-session/handlers/backchannel-logout.test.ts @@ -1,6 +1,7 @@ import { setup, teardown } from '../fixtures/server'; import { defaultConfig, post } from '../fixtures/helpers'; import { makeLogoutToken } from '../fixtures/cert'; +import { isLoggedOut, getConfig } from '../../../src/auth0-session'; class Store { public store: { [key: string]: any }; @@ -24,18 +25,21 @@ describe('backchannel-logout', () => { afterEach(teardown); it('should fail when logout_token is missing', async () => { - const baseURL = await setup({ ...defaultConfig, session: { store: new Store(), genId: () => 'foo' } }); + const params = { ...defaultConfig, session: { store: new Store(), genId: () => 'foo' } }; + const baseURL = await setup(params); await expect(post(baseURL, '/backchannel-logout', { body: {} })).rejects.toThrow('Missing Logout Token'); + await expect(isLoggedOut({ sid: 'foo' }, getConfig({ baseURL, ...params }))).resolves.toEqual(false); }); it('should succeed with valid logout token', async () => { const store = new Store(); - const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const params = { ...defaultConfig, session: { store, genId: () => 'foo' } }; + const baseURL = await setup(params); const { res } = await post(baseURL, '/backchannel-logout', { body: { logout_token: await makeLogoutToken({ sid: 'foo' }) }, fullResponse: true }); - await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ header: { maxAge: 24 * 60 * 60 * 1000, exp: expect.any(Number) @@ -43,35 +47,43 @@ describe('backchannel-logout', () => { data: {} }); expect(res.statusCode).toEqual(204); + await expect(isLoggedOut({ sid: 'foo' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); }); it('should save sid and sub', async () => { const store = new Store(); - const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const params = { ...defaultConfig, session: { store, genId: () => 'foo' } }; + const baseURL = await setup(params); const { res } = await post(baseURL, '/backchannel-logout', { body: { logout_token: await makeLogoutToken({ sid: 'foo', sub: 'bar' }) }, fullResponse: true }); - await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ data: {} }); - await expect(store.get('https://op.example.com/|bar')).resolves.toMatchObject({ + await expect(store.get('sub|__test_client_id__|bar')).resolves.toMatchObject({ data: {} }); expect(res.statusCode).toEqual(204); + await expect(isLoggedOut({ sid: 'foo' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); + await expect(isLoggedOut({ sub: 'bar' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); + await expect(isLoggedOut({ sid: 'foo', sub: 'bar' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); + await expect(isLoggedOut({ sub: 'foo', sid: 'bar' }, getConfig({ baseURL, ...params }))).resolves.toEqual(false); }); it('should save just sub', async () => { const store = new Store(); - const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo' } }); + const params = { ...defaultConfig, session: { store, genId: () => 'foo' } }; + const baseURL = await setup(params); const { res } = await post(baseURL, '/backchannel-logout', { body: { logout_token: await makeLogoutToken({ sub: 'bar' }) }, fullResponse: true }); - await expect(store.get('https://op.example.com/|bar')).resolves.toMatchObject({ + await expect(store.get('sub|__test_client_id__|bar')).resolves.toMatchObject({ data: {} }); expect(res.statusCode).toEqual(204); + await expect(isLoggedOut({ sub: 'bar' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); }); it('should save logout with absolute duration', async () => { @@ -81,7 +93,7 @@ describe('backchannel-logout', () => { body: { logout_token: await makeLogoutToken({ sid: 'foo' }) }, fullResponse: true }); - await expect(store.get('https://op.example.com/|foo')).resolves.toMatchObject({ + await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ header: { maxAge: 7 * 24 * 60 * 60 * 1000, exp: expect.any(Number) From 8789b9a7bede33694746fc1d4f02b2e694c4df8a Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Mon, 27 Nov 2023 14:18:12 +0000 Subject: [PATCH 3/8] Add backchannel logout to session cache --- .../handlers/backchannel-logout.ts | 37 +++++++++++------- src/auth0-session/index.ts | 4 +- src/session/cache.ts | 37 ++++++++++++++---- tests/auth0-session/fixtures/helpers.ts | 18 +++++++++ .../handlers/backchannel-logout.test.ts | 32 ++++++--------- .../session/stateful-session.test.ts | 20 +--------- tests/config.test.ts | 3 +- tests/session/cache.test.ts | 39 ++++++++++++++++++- tests/stateful-session.test.ts | 20 +--------- 9 files changed, 129 insertions(+), 81 deletions(-) diff --git a/src/auth0-session/handlers/backchannel-logout.ts b/src/auth0-session/handlers/backchannel-logout.ts index 76b8dadc3..ad49f99ef 100644 --- a/src/auth0-session/handlers/backchannel-logout.ts +++ b/src/auth0-session/handlers/backchannel-logout.ts @@ -4,6 +4,14 @@ import { GetClient } from '../client/abstract-client'; import getLogoutTokenVerifier from '../utils/logout-token-verifier'; import * as querystring from 'querystring'; +const getStore = (config: Config) => { + const { + session: { store }, + backchannelLogout + } = config; + return typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store; +}; + export type HandleBackchannelLogout = (req: Auth0Request, res: Auth0Response) => Promise; export default function backchannelLogoutHandlerFactory( @@ -31,10 +39,9 @@ export default function backchannelLogoutHandlerFactory( const token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata()); const { clientID, - session: { absoluteDuration, rolling: rollingEnabled, rollingDuration, store }, - backchannelLogout + session: { absoluteDuration, rolling: rollingEnabled, rollingDuration } } = config; - const backchannelLogoutStore = typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store; + const store = getStore(config); const maxAge = (rollingEnabled ? Math.min(absoluteDuration as number, rollingDuration as number) @@ -46,8 +53,8 @@ export default function backchannelLogoutHandlerFactory( }; const { sid, sub } = token; await Promise.all([ - sid && backchannelLogoutStore.set(`sid|${clientID}|${sid}`, payload), - sub && backchannelLogoutStore.set(`sub|${clientID}|${sub}`, payload) + sid && store.set(`sid|${clientID}|${sid}`, payload), + sub && store.set(`sub|${clientID}|${sub}`, payload) ]); res.send204(); }; @@ -56,16 +63,20 @@ export default function backchannelLogoutHandlerFactory( export type IsLoggedOut = (user: { [key: string]: any }, config: Config) => Promise; export const isLoggedOut: IsLoggedOut = async (user, config) => { - const { - clientID, - session: { store }, - backchannelLogout - } = config; - const backchannelLogoutStore = typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store; + const { clientID } = config; + const store = getStore(config); const { sid, sub } = user; const [logoutSid, logoutSub] = await Promise.all([ - backchannelLogoutStore.get(`sid|${clientID}|${sid}`), - backchannelLogoutStore.get(`sub|${clientID}|${sub}`) + store.get(`sid|${clientID}|${sid}`), + store.get(`sub|${clientID}|${sub}`) ]); return !!(logoutSid || logoutSub); }; + +export type DeleteSub = (sub: string, config: Config) => Promise; + +export const deleteSub: DeleteSub = async (sub, config) => { + const { clientID } = config; + const store = getStore(config); + await store.delete(`sub|${clientID}|${sub}`); +}; diff --git a/src/auth0-session/index.ts b/src/auth0-session/index.ts index bb50c6634..9131d4881 100644 --- a/src/auth0-session/index.ts +++ b/src/auth0-session/index.ts @@ -26,7 +26,9 @@ export { default as backchannelLogoutHandler, HandleBackchannelLogout, isLoggedOut, - IsLoggedOut + IsLoggedOut, + DeleteSub, + deleteSub } from './handlers/backchannel-logout'; export { TokenEndpointResponse, AbstractClient, Telemetry } from './client/abstract-client'; export { SessionCache } from './session-cache'; diff --git a/src/session/cache.ts b/src/session/cache.ts index 6b7bdab3a..119e6745c 100644 --- a/src/session/cache.ts +++ b/src/session/cache.ts @@ -2,7 +2,14 @@ import { IncomingMessage, ServerResponse } from 'http'; import { NextApiRequest, NextApiResponse } from 'next'; import { NextRequest, NextResponse } from 'next/server'; import type { TokenEndpointResponse } from '../auth0-session'; -import { SessionCache as ISessionCache, AbstractSession, StatefulSession, StatelessSession } from '../auth0-session'; +import { + SessionCache as ISessionCache, + AbstractSession, + StatefulSession, + StatelessSession, + isLoggedOut, + deleteSub +} from '../auth0-session'; import Session, { fromJson, fromTokenEndpointResponse } from './session'; import { Auth0Request, Auth0Response, NodeRequest, NodeResponse } from '../auth0-session/http'; import { @@ -54,10 +61,16 @@ export default class SessionCache implements ISessionCache { const config = await this.getConfig(auth0Req); const sessionStore = this.getSessionStore(config); const [json, iat] = await sessionStore.read(auth0Req); - this.iatCache.set(req, iat); - this.cache.set(req, fromJson(json)); - if (config.session.rolling && config.session.autoSave && autoSave) { + const session = fromJson(json); + if (session && config.backchannelLogout && (await isLoggedOut(session.user, config))) { + this.cache.set(req, null); await this.save(req, res); + } else { + this.iatCache.set(req, iat); + this.cache.set(req, session); + if (config.session.rolling && config.session.autoSave && autoSave) { + await this.save(req, res); + } } } } @@ -70,6 +83,11 @@ export default class SessionCache implements ISessionCache { } async create(req: Req, res: Res, session: Session): Promise { + const [auth0Req] = getAuth0ReqRes(req, res); + const config = await this.getConfig(auth0Req); + if (config.backchannelLogout) { + await deleteSub(session.user.sub, config); + } this.cache.set(req, session); await this.save(req, res); } @@ -130,10 +148,15 @@ export const get = async ({ } = config; const [json, iat] = await sessionStore.read(auth0Req); const session = fromJson(json); - if (rolling && autoSave) { - await set({ session, sessionCache, iat }); + if (session && config.backchannelLogout && (await isLoggedOut(session.user, config))) { + await set({ session: null, sessionCache }); + return []; + } else { + if (rolling && autoSave) { + await set({ session, sessionCache, iat }); + } + return [session, iat]; } - return [session, iat]; }; export const set = async ({ diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts index e448eeef8..485f04137 100644 --- a/tests/auth0-session/fixtures/helpers.ts +++ b/tests/auth0-session/fixtures/helpers.ts @@ -129,3 +129,21 @@ export const decodeJWT = ( signature }; }; + +export class Store { + public store: { [key: string]: any }; + constructor() { + this.store = {}; + } + get(id: string) { + return Promise.resolve(this.store[id]); + } + async set(id: string, val: any) { + this.store[id] = val; + await Promise.resolve(); + } + async delete(id: string) { + delete this.store[id]; + await Promise.resolve(); + } +} diff --git a/tests/auth0-session/handlers/backchannel-logout.test.ts b/tests/auth0-session/handlers/backchannel-logout.test.ts index 77964e386..a9f97d12a 100644 --- a/tests/auth0-session/handlers/backchannel-logout.test.ts +++ b/tests/auth0-session/handlers/backchannel-logout.test.ts @@ -1,25 +1,7 @@ import { setup, teardown } from '../fixtures/server'; -import { defaultConfig, post } from '../fixtures/helpers'; +import { defaultConfig, post, Store } from '../fixtures/helpers'; import { makeLogoutToken } from '../fixtures/cert'; -import { isLoggedOut, getConfig } from '../../../src/auth0-session'; - -class Store { - public store: { [key: string]: any }; - constructor() { - this.store = {}; - } - get(id: string) { - return Promise.resolve(this.store[id]); - } - async set(id: string, val: any) { - this.store[id] = val; - await Promise.resolve(); - } - async delete(id: string) { - delete this.store[id]; - await Promise.resolve(); - } -} +import { isLoggedOut, getConfig, deleteSub } from '../../../src/auth0-session'; describe('backchannel-logout', () => { afterEach(teardown); @@ -102,4 +84,14 @@ describe('backchannel-logout', () => { }); expect(res.statusCode).toEqual(204); }); + + it('should delete a sub entry from the logout store', async () => { + const store = new Store(); + const config = getConfig({ ...defaultConfig, session: { store, genId: () => 'foo' }, baseURL: 'http://localhost' }); + await expect(isLoggedOut({ sub: 'bar' }, config)).resolves.toEqual(false); + await store.set('sub|__test_client_id__|bar', {}); + await expect(isLoggedOut({ sub: 'bar' }, config)).resolves.toEqual(true); + await deleteSub('bar', config); + await expect(isLoggedOut({ sub: 'bar' }, config)).resolves.toEqual(false); + }); }); diff --git a/tests/auth0-session/session/stateful-session.test.ts b/tests/auth0-session/session/stateful-session.test.ts index 20d27edb1..99f7e234b 100644 --- a/tests/auth0-session/session/stateful-session.test.ts +++ b/tests/auth0-session/session/stateful-session.test.ts @@ -1,6 +1,6 @@ import { TokenSet } from 'openid-client'; import { setup, teardown } from '../fixtures/server'; -import { defaultConfig, get, post, toSignedCookieJar } from '../fixtures/helpers'; +import { defaultConfig, get, post, toSignedCookieJar, Store } from '../fixtures/helpers'; import { ConfigParameters, SessionStore } from '../../../src/auth0-session'; import { SessionPayload } from '../../../src/auth0-session/session/abstract-session'; import { makeIdToken } from '../fixtures/cert'; @@ -28,24 +28,6 @@ const login = async (baseURL: string, existingSession?: { appSession: string }): return cookieJar; }; -class Store { - public store: { [key: string]: any }; - constructor() { - this.store = {}; - } - get(id: string) { - return Promise.resolve(this.store[id]); - } - async set(id: string, val: any) { - this.store[id] = val; - await Promise.resolve(); - } - async delete(id: string) { - delete this.store[id]; - await Promise.resolve(); - } -} - const getPayload = async ( data = { sub: 'dave' }, iat = epochNow, diff --git a/tests/config.test.ts b/tests/config.test.ts index 4afd999e6..4710fb8ea 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -90,7 +90,8 @@ describe('config params', () => { sameSite: 'lax', secure: true }, - organization: undefined + organization: undefined, + backchannelLogout: false }); }); diff --git a/tests/session/cache.test.ts b/tests/session/cache.test.ts index b2d6210b6..4b1e433f7 100644 --- a/tests/session/cache.test.ts +++ b/tests/session/cache.test.ts @@ -1,10 +1,11 @@ import { IncomingMessage, ServerResponse } from 'http'; import { Socket } from 'net'; -import { StatelessSession } from '../../src/auth0-session'; +import { isLoggedOut, StatelessSession } from '../../src/auth0-session'; import { getConfig } from '../../src/config'; import { get, set } from '../../src/session/cache'; import { ConfigParameters, Session, SessionCache } from '../../src'; import { withoutApi } from '../fixtures/default-settings'; +import { Store } from '../auth0-session/fixtures/helpers'; describe('SessionCache', () => { let cache: SessionCache; @@ -44,6 +45,17 @@ describe('SessionCache', () => { ); }); + test(`should create the session entry and delete the user's logout entry`, async () => { + const store = new Store(); + const params = { ...withoutApi, backchannelLogout: { store } }; + setup(params); + await store.set(`sub|${withoutApi.clientID}|${session.user.sub}`, {}); + await expect(isLoggedOut(session.user, getConfig(params))).resolves.toEqual(true); + await cache.create(req, res, session); + await expect(store.get(`sub|${withoutApi.clientID}|${session.user.sub}`)).resolves.toBeUndefined(); + await expect(isLoggedOut(session.user, getConfig(params))).resolves.toEqual(false); + }); + test('should delete the session entry', async () => { await cache.create(req, res, session); expect(await cache.get(req, res)).toEqual(session); @@ -65,6 +77,20 @@ describe('SessionCache', () => { expect(await cache.getIdToken(req, res)).toEqual('__test_id_token__'); }); + test('should logout a user via back-channel', async () => { + const store = new Store(); + const params = { ...withoutApi, backchannelLogout: { store } }; + setup(params); + sessionStore.read = jest.fn().mockResolvedValue([session, 500]); + await expect(cache.isAuthenticated(req, res)).resolves.toEqual(true); + await store.set(`sub|${withoutApi.clientID}|${session.user.sub}`, {}); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cache.cache.delete(req); // clear cache + await expect(cache.isAuthenticated(req, res)).resolves.toEqual(false); + expect(sessionStore.save).toHaveBeenCalledWith(expect.anything(), expect.anything(), null, 500); + }); + test('should get no id token for anonymous user', async () => { expect(await cache.getIdToken(req, res)).toBeUndefined(); }); @@ -115,4 +141,15 @@ describe('SessionCache', () => { const [session] = await get({ sessionCache: cache }); expect(session).toBeInstanceOf(Session); }); + + test('should logout a user via back-channel from RSC', async () => { + const store = new Store(); + const params = { ...withoutApi, backchannelLogout: { store } }; + setup(params); + sessionStore.read = jest.fn().mockResolvedValue([session, 500]); + expect((await get({ sessionCache: cache }))[0]?.user).toEqual(session.user); + await store.set(`sub|${withoutApi.clientID}|${session.user.sub}`, {}); + expect((await get({ sessionCache: cache }))[0]?.user).toBeUndefined(); + expect(sessionStore.save).toHaveBeenCalledWith(expect.anything(), expect.anything(), null, undefined); + }); }); diff --git a/tests/stateful-session.test.ts b/tests/stateful-session.test.ts index fa8041011..8ad6ae261 100644 --- a/tests/stateful-session.test.ts +++ b/tests/stateful-session.test.ts @@ -1,5 +1,5 @@ import { withoutApi } from './fixtures/default-settings'; -import { get, toSignedCookieJar } from './auth0-session/fixtures/helpers'; +import { get, toSignedCookieJar, Store } from './auth0-session/fixtures/helpers'; import { setup, teardown, login } from './fixtures/setup'; import { SessionPayload } from '../src/auth0-session/session/abstract-session'; import { makeIdToken } from './auth0-session/fixtures/cert'; @@ -10,24 +10,6 @@ const hr = 60 * 60 * 1000; const day = 24 * hr; const epochNow = (Date.now() / 1000) | 0; -class Store { - public store: { [key: string]: any }; - constructor() { - this.store = {}; - } - get(id: string) { - return Promise.resolve(this.store[id]); - } - async set(id: string, val: any) { - this.store[id] = val; - await Promise.resolve(); - } - async delete(id: string) { - delete this.store[id]; - await Promise.resolve(); - } -} - const getPayload = async ( data = { sub: 'dave' }, iat = epochNow, From 821a33d2e4b30273a0da1bc0e3d2b23db0dd93fc Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Thu, 30 Nov 2023 11:23:33 +0000 Subject: [PATCH 4/8] Add backchannel logout app/page router handlers --- jest-edge.config.js | 1 + .../handlers/backchannel-logout.ts | 26 ++++-- src/auth0-session/utils/errors.ts | 8 ++ .../utils/logout-token-verifier.ts | 8 +- src/handlers/auth.ts | 6 +- src/handlers/backchannel-logout.ts | 71 +++++++++++++++ src/handlers/index.ts | 1 + src/init.ts | 21 ++++- src/shared.ts | 15 ++- tests/auth0-session/fixtures/helpers.ts | 16 +++- tests/auth0-session/fixtures/server.ts | 19 ++-- .../handlers/backchannel-logout.test.ts | 15 +++ .../utils/logout-token-verifier.test.ts | 2 +- tests/fixtures/app-router-helpers.ts | 6 +- tests/fixtures/setup.ts | 16 +++- .../backchannel-logout-page-router.test.ts | 62 +++++++++++++ tests/handlers/backchannel-logout.test.ts | 91 +++++++++++++++++++ 17 files changed, 350 insertions(+), 34 deletions(-) create mode 100644 src/handlers/backchannel-logout.ts create mode 100644 tests/handlers/backchannel-logout-page-router.test.ts create mode 100644 tests/handlers/backchannel-logout.test.ts diff --git a/jest-edge.config.js b/jest-edge.config.js index 19bfac4db..1121c6fc4 100644 --- a/jest-edge.config.js +++ b/jest-edge.config.js @@ -10,6 +10,7 @@ module.exports = { '**/tests/handlers/logout.test.ts', '**/tests/handlers/callback.test.ts', '**/tests/handlers/profile.test.ts', + '**/tests/handlers/backchannel-logout.test.ts', '**/tests/http/auth0-next-request.test.ts', '**/tests/http/auth0-next-response.test.ts', '**/tests/helpers/with-middleware-auth-required.test.ts', diff --git a/src/auth0-session/handlers/backchannel-logout.ts b/src/auth0-session/handlers/backchannel-logout.ts index ad49f99ef..cd0eb3846 100644 --- a/src/auth0-session/handlers/backchannel-logout.ts +++ b/src/auth0-session/handlers/backchannel-logout.ts @@ -3,6 +3,8 @@ import { Config, GetConfig } from '../config'; import { GetClient } from '../client/abstract-client'; import getLogoutTokenVerifier from '../utils/logout-token-verifier'; import * as querystring from 'querystring'; +import { BackchannelLogoutError } from '../utils/errors'; +import { JWTPayload } from 'jose'; const getStore = (config: Config) => { const { @@ -28,15 +30,21 @@ export default function backchannelLogoutHandlerFactory( if (typeof body === 'string') { try { body = querystring.parse(body) as Record; + /* c8 ignore next 3 */ } catch (e) { body = {}; } } const logoutToken = (body as Record).logout_token; if (!logoutToken) { - throw new Error('Missing Logout Token'); + throw new BackchannelLogoutError('invalid_request', 'Missing Logout Token'); + } + let token: JWTPayload; + try { + token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata()); + } catch (e) { + throw new BackchannelLogoutError('invalid_request', e.message); } - const token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata()); const { clientID, session: { absoluteDuration, rolling: rollingEnabled, rollingDuration } @@ -51,11 +59,15 @@ export default function backchannelLogoutHandlerFactory( header: { iat: now, uat: now, exp: now + maxAge, maxAge }, data: {} }; - const { sid, sub } = token; - await Promise.all([ - sid && store.set(`sid|${clientID}|${sid}`, payload), - sub && store.set(`sub|${clientID}|${sub}`, payload) - ]); + try { + const { sid, sub } = token; + await Promise.all([ + sid && store.set(`sid|${clientID}|${sid}`, payload), + sub && store.set(`sub|${clientID}|${sub}`, payload) + ]); + } catch (e) { + throw new BackchannelLogoutError('application_error', e.message); + } res.send204(); }; } diff --git a/src/auth0-session/utils/errors.ts b/src/auth0-session/utils/errors.ts index 96126b69c..88b1595d7 100644 --- a/src/auth0-session/utils/errors.ts +++ b/src/auth0-session/utils/errors.ts @@ -101,6 +101,14 @@ export class UserInfoError extends EscapedError { } } +export class BackchannelLogoutError extends Error { + constructor(public code: string, public description: string) { + /* c8 ignore next */ + super(description); + Object.setPrototypeOf(this, BackchannelLogoutError.prototype); + } +} + // eslint-disable-next-line max-len // Basic escaping for putting untrusted data directly into the HTML body, per: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content. export function htmlSafe(input?: string): string | undefined { diff --git a/src/auth0-session/utils/logout-token-verifier.ts b/src/auth0-session/utils/logout-token-verifier.ts index 3d6f138a3..a189da974 100644 --- a/src/auth0-session/utils/logout-token-verifier.ts +++ b/src/auth0-session/utils/logout-token-verifier.ts @@ -11,8 +11,6 @@ import { IssuerMetadata } from '../client/abstract-client'; type GetKeyFn = GetKeyFunction; -const isObject = (a: any) => !!a && a.constructor === Object; - export type VerifyLogoutToken = ( logoutToken: string, config: Config, @@ -51,7 +49,7 @@ export default function getLogoutTokenVerifier(): VerifyLogoutToken { throw new Error('"events" claim is missing'); } - if (!isObject(payload.events)) { + if (typeof payload.events !== 'object' || payload.events === null) { throw new Error('"events" claim must be an object'); } @@ -59,7 +57,9 @@ export default function getLogoutTokenVerifier(): VerifyLogoutToken { throw new Error('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim'); } - if (!isObject((payload as { events?: any }).events['http://schemas.openid.net/event/backchannel-logout'])) { + if ( + typeof (payload as { events?: any }).events['http://schemas.openid.net/event/backchannel-logout'] !== 'object' + ) { throw new Error( '"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object' ); diff --git a/src/handlers/auth.ts b/src/handlers/auth.ts index e5ac0f4ea..51dc07bd2 100644 --- a/src/handlers/auth.ts +++ b/src/handlers/auth.ts @@ -3,6 +3,7 @@ import { NextRequest } from 'next/server'; import { HandleLogin } from './login'; import { HandleLogout } from './logout'; import { HandleCallback } from './callback'; +import { HandleBackchannelLogout } from './backchannel-logout'; import { HandleProfile } from './profile'; import { HandlerError } from '../utils/errors'; import { @@ -169,18 +170,21 @@ export default function handlerFactory({ handleLogin, handleLogout, handleCallback, - handleProfile + handleProfile, + handleBackchannelLogout }: { handleLogin: HandleLogin; handleLogout: HandleLogout; handleCallback: HandleCallback; handleProfile: HandleProfile; + handleBackchannelLogout: HandleBackchannelLogout; }): HandleAuth { return ({ onError, ...handlers }: Handlers = {}): NextApiHandler | AppRouteHandlerFn => { const customHandlers: ApiHandlers = { login: handleLogin, logout: handleLogout, callback: handleCallback, + 'backchannel-logout': handleBackchannelLogout, me: (handlers as ApiHandlers).profile || handleProfile, ...handlers }; diff --git a/src/handlers/backchannel-logout.ts b/src/handlers/backchannel-logout.ts new file mode 100644 index 000000000..0cea011b4 --- /dev/null +++ b/src/handlers/backchannel-logout.ts @@ -0,0 +1,71 @@ +import { NextApiResponse, NextApiRequest } from 'next'; +import { NextRequest, NextResponse } from 'next/server'; +import { HandleBackchannelLogout as BaseHandleBackchannelLogout } from '../auth0-session'; +import { Auth0NextApiRequest, Auth0NextApiResponse, Auth0NextRequest, Auth0NextResponse } from '../http'; +import { AppRouteHandlerFnContext, Handler, getHandler } from './router-helpers'; +import { GetConfig } from '../config'; + +/** + * The handler for the POST `/api/auth/backchannel-logout` API route. + * + * @category Server + */ +export type HandleBackchannelLogout = Handler; + +/** + * @ignore + */ +export default function handleBackchannelLogoutFactory( + handler: BaseHandleBackchannelLogout, + getConfig: GetConfig +): HandleBackchannelLogout { + const appRouteHandler = appRouteHandlerFactory(handler, getConfig); + const pageRouteHandler = pageRouteHandlerFactory(handler, getConfig); + + return getHandler(appRouteHandler, pageRouteHandler) as HandleBackchannelLogout; +} + +const appRouteHandlerFactory: ( + handler: BaseHandleBackchannelLogout, + getConfig: GetConfig +) => (req: NextRequest, ctx: AppRouteHandlerFnContext) => Promise | Response = + (handler, getConfig) => async (req) => { + try { + const auth0Req = new Auth0NextRequest(req); + const config = await getConfig(auth0Req); + if (!config.backchannelLogout) { + return new NextResponse('Back-Channel Logout is not enabled.', { status: 404 }); + } + const auth0Res = new Auth0NextResponse(new NextResponse()); + await handler(auth0Req, auth0Res); + return auth0Res.res; + } catch (e) { + return NextResponse.json( + { + error: e.code || 'unknown_error', + error_description: e.description || e.message + }, + { status: 400 } + ); + } + }; + +const pageRouteHandlerFactory: ( + handler: BaseHandleBackchannelLogout, + getConfig: GetConfig +) => (req: NextApiRequest, res: NextApiResponse) => Promise | void = (handler, getConfig) => async (req, res) => { + try { + const auth0Req = new Auth0NextApiRequest(req); + const config = await getConfig(auth0Req); + if (!config.backchannelLogout) { + res.status(404).end('Back-Channel Logout is not enabled.'); + return; + } + return await handler(auth0Req, new Auth0NextApiResponse(res)); + } catch (e) { + res.status(400).json({ + error: e.code || 'unknown_error', + error_description: e.description || e.message + }); + } +}; diff --git a/src/handlers/index.ts b/src/handlers/index.ts index cf57d7482..3b451d115 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -15,6 +15,7 @@ export { GetLoginStateAppRoute } from './login'; export { default as logoutHandler, HandleLogout, LogoutOptions } from './logout'; +export { default as backchannelLogoutHandler, HandleBackchannelLogout } from './backchannel-logout'; export { default as profileHandler, HandleProfile, diff --git a/src/init.ts b/src/init.ts index 86b718851..8b96e8c5d 100644 --- a/src/init.ts +++ b/src/init.ts @@ -3,9 +3,17 @@ import { loginHandler as baseLoginHandler, logoutHandler as baseLogoutHandler, callbackHandler as baseCallbackHandler, + backchannelLogoutHandler as baseBackchannelLogoutHandler, Telemetry } from './auth0-session'; -import { handlerFactory, callbackHandler, loginHandler, logoutHandler, profileHandler } from './handlers'; +import { + handlerFactory, + callbackHandler, + loginHandler, + logoutHandler, + profileHandler, + backchannelLogoutHandler +} from './handlers'; import { sessionFactory, accessTokenFactory, @@ -47,6 +55,7 @@ export const _initAuth = ({ const baseHandleLogin = baseLoginHandler(getConfig, getClient, transientStore); const baseHandleLogout = baseLogoutHandler(getConfig, getClient, sessionCache); const baseHandleCallback = baseCallbackHandler(getConfig, getClient, sessionCache, transientStore); + const baseHandleBackchannelLogout = baseBackchannelLogoutHandler(getConfig, getClient); // Init Next layer (with next config) const getSession = sessionFactory(sessionCache); @@ -58,8 +67,15 @@ export const _initAuth = ({ const handleLogin = loginHandler(baseHandleLogin, getConfig); const handleLogout = logoutHandler(baseHandleLogout); const handleCallback = callbackHandler(baseHandleCallback, getConfig); + const handleBackchannelLogout = backchannelLogoutHandler(baseHandleBackchannelLogout, getConfig); const handleProfile = profileHandler(getConfig, getClient, getAccessToken, sessionCache); - const handleAuth = handlerFactory({ handleLogin, handleLogout, handleCallback, handleProfile }); + const handleAuth = handlerFactory({ + handleLogin, + handleLogout, + handleCallback, + handleProfile, + handleBackchannelLogout + }); const withMiddlewareAuthRequired = withMiddlewareAuthRequiredFactory(getConfig, sessionCache); return { @@ -72,6 +88,7 @@ export const _initAuth = ({ handleLogin, handleLogout, handleCallback, + handleBackchannelLogout, handleProfile, handleAuth, withMiddlewareAuthRequired diff --git a/src/shared.ts b/src/shared.ts index d65c06006..ec4ef375c 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,5 +1,12 @@ import { SessionStore as GenericSessionStore, SessionPayload } from './auth0-session'; -import { HandleAuth, HandleLogin, HandleProfile, HandleLogout, HandleCallback } from './handlers'; +import { + HandleAuth, + HandleLogin, + HandleProfile, + HandleLogout, + HandleCallback, + HandleBackchannelLogout +} from './handlers'; import { SessionCache, GetSession, GetAccessToken, Session, TouchSession, UpdateSession } from './session/'; import { WithApiAuthRequired, WithPageAuthRequired } from './helpers'; import { ConfigParameters } from './config'; @@ -53,6 +60,11 @@ export interface Auth0Server { */ handleLogout: HandleLogout; + /** + * Logout handler which will clear the local session and the Auth0 session. + */ + handleBackchannelLogout: HandleBackchannelLogout; + /** * Profile handler which return profile information about the user. */ @@ -152,6 +164,7 @@ export { HandleProfile, HandleLogout, HandleCallback, + HandleBackchannelLogout, WithApiAuthRequired, WithPageAuthRequired, SessionCache, diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts index 485f04137..0b512b36d 100644 --- a/tests/auth0-session/fixtures/helpers.ts +++ b/tests/auth0-session/fixtures/helpers.ts @@ -50,7 +50,11 @@ export const getCookie = (findKey: string, cookieJar: CookieJar, url: string): C const request = ( url: string, method = 'GET', - { body, cookieJar, fullResponse }: { body?: { [key: string]: string }; cookieJar?: CookieJar; fullResponse?: boolean } + { + body, + cookieJar, + fullResponse + }: { body?: { [key: string]: string } | string; cookieJar?: CookieJar; fullResponse?: boolean } ): Promise<{ [key: string]: string } | string | { data: { [key: string]: string } | string; res: IncomingMessage }> => new Promise((resolve, reject) => { const { pathname, port, protocol, search = '' } = new URL(url); @@ -90,13 +94,17 @@ const request = ( }); } ); - req.setHeader('content-type', 'application/json'); + if (typeof body === 'string') { + req.setHeader('content-type', 'application/x-www-form-urlencoded'); + } else { + req.setHeader('content-type', 'application/json'); + } if (cookieJar) { req.setHeader('cookie', cookieJar.getCookieStringSync(url)); } req.on('error', reject); if (body) { - req.write(JSON.stringify(body)); + req.write(typeof body === 'string' ? body : JSON.stringify(body)); } req.end(); }); @@ -116,7 +124,7 @@ export const post = async ( cookieJar, body, fullResponse - }: { body: { [key: string]: any }; cookieJar?: CookieJar; fullResponse?: boolean; https?: boolean } + }: { body: { [key: string]: any } | string; cookieJar?: CookieJar; fullResponse?: boolean; https?: boolean } ): Promise => request(`${baseURL}${path}`, 'POST', { body, cookieJar, fullResponse }); export const decodeJWT = ( diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts index e6b96269a..50f2947dd 100644 --- a/tests/auth0-session/fixtures/server.ts +++ b/tests/auth0-session/fixtures/server.ts @@ -27,6 +27,7 @@ import version from '../../../src/version'; import { NodeRequest, NodeResponse } from '../../../src/auth0-session/http'; import { clientGetter } from '../../../src/auth0-session/client/node-client'; import backchannelLogoutHandlerFactory from '../../../src/auth0-session/handlers/backchannel-logout'; +import { promisify } from 'util'; export type SessionResponse = TokenSetParameters & { claims: Claims }; @@ -102,16 +103,14 @@ const createHandlers = (params: ConfigParameters): Handlers => { export const parseJson = async (req: IncomingMessage, res: ServerResponse): Promise => { const { default: bodyParser } = await import('body-parser'); - const jsonParse = bodyParser.json(); - return await new Promise((resolve, reject) => { - jsonParse(req, res, (error: Error | undefined) => { - if (error) { - reject(error); - } else { - resolve(req); - } - }); - }); + const jsonParser = promisify(bodyParser.json()); + const formParser = promisify(bodyParser.urlencoded({ extended: true })); + if (req.headers['content-type'] === 'application/json') { + await jsonParser(req, res); + } else { + await formParser(req, res); + } + return req; }; const requestListener = diff --git a/tests/auth0-session/handlers/backchannel-logout.test.ts b/tests/auth0-session/handlers/backchannel-logout.test.ts index a9f97d12a..4269f878d 100644 --- a/tests/auth0-session/handlers/backchannel-logout.test.ts +++ b/tests/auth0-session/handlers/backchannel-logout.test.ts @@ -68,6 +68,21 @@ describe('backchannel-logout', () => { await expect(isLoggedOut({ sub: 'bar' }, getConfig({ baseURL, ...params }))).resolves.toEqual(true); }); + it('should fail when saving fails', async () => { + const store = new Store(); + store.set = function () { + throw new Error('saving failed'); + }; + const params = { ...defaultConfig, session: { store, genId: () => 'foo' } }; + const baseURL = await setup(params); + await expect( + post(baseURL, '/backchannel-logout', { + body: { logout_token: await makeLogoutToken({ sub: 'bar' }) }, + fullResponse: true + }) + ).rejects.toThrow('saving failed'); + }); + it('should save logout with absolute duration', async () => { const store = new Store(); const baseURL = await setup({ ...defaultConfig, session: { store, genId: () => 'foo', rolling: false } }); diff --git a/tests/auth0-session/utils/logout-token-verifier.test.ts b/tests/auth0-session/utils/logout-token-verifier.test.ts index 6921ecd5d..2be5f4ee9 100644 --- a/tests/auth0-session/utils/logout-token-verifier.test.ts +++ b/tests/auth0-session/utils/logout-token-verifier.test.ts @@ -69,7 +69,7 @@ describe('logoutTokenVerifier', () => { }); it('should fail when events not an object', async () => { - const token = await makeLogoutToken({ events: [], sid: 'foo' }); + const token = await makeLogoutToken({ events: true, sid: 'foo' }); await expect(verify(token, getConfig(withApi), metadata)).rejects.toThrow('"events" claim must be an object'); expect(jwksSpy).toHaveBeenCalled(); }); diff --git a/tests/fixtures/app-router-helpers.ts b/tests/fixtures/app-router-helpers.ts index f2af60c32..2b46391f8 100644 --- a/tests/fixtures/app-router-helpers.ts +++ b/tests/fixtures/app-router-helpers.ts @@ -61,6 +61,7 @@ export type GetResponseOpts = { extraHandlers?: any; clearNock?: boolean; auth0Instance?: Auth0Server; + reqInit?: RequestInit; }; export type LoginOpts = Omit; @@ -79,7 +80,8 @@ export const getResponse = async ({ profileOpts, extraHandlers, clearNock = true, - auth0Instance + auth0Instance, + reqInit }: GetResponseOpts) => { const opts = { ...withApi, ...config }; clearNock && nock.cleanAll(); @@ -105,7 +107,7 @@ export const getResponse = async ({ .join('; ') ); } - return handleAuth(new NextRequest(new URL(url, opts.baseURL), { headers }), { params: { auth0 } }); + return handleAuth(new NextRequest(new URL(url, opts.baseURL), { headers, ...reqInit } as any), { params: { auth0 } }); }; export const getSession = async (config: any, res: NextResponse) => { diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts index b4144f590..038ad0f98 100644 --- a/tests/fixtures/setup.ts +++ b/tests/fixtures/setup.ts @@ -16,7 +16,8 @@ import { HandleLogin, HandleLogout, HandleCallback, - HandleProfile + HandleProfile, + HandleBackchannelLogout } from '../../src'; import { codeExchange, discovery, jwksEndpoint, userInfo } from './oidc-nocks'; import { jwks, makeIdToken } from '../auth0-session/fixtures/cert'; @@ -33,6 +34,7 @@ export type SetupOptions = { logoutHandler?: HandleLogout; logoutOptions?: LogoutOptions; profileHandler?: HandleProfile; + backchannelLogoutHandler?: HandleBackchannelLogout; profileOptions?: ProfileOptions; withPageAuthRequiredOptions?: WithPageAuthRequiredPageRouterOptions; getAccessTokenOptions?: AccessTokenRequest; @@ -74,6 +76,7 @@ export const setup = async ( loginHandler, loginOptions, profileHandler, + backchannelLogoutHandler, profileOptions, withPageAuthRequiredOptions, onError = defaultOnError, @@ -90,6 +93,7 @@ export const setup = async ( handleCallback, handleLogin, handleLogout, + handleBackchannelLogout, handleProfile, getSession, touchSession, @@ -102,7 +106,15 @@ export const setup = async ( const login: NextApiHandler = (...args) => (loginHandler || handleLogin)(...args, loginOptions); const logout: NextApiHandler = (...args) => (logoutHandler || handleLogout)(...args, logoutOptions); const profile: NextApiHandler = (...args) => (profileHandler || handleProfile)(...args, profileOptions); - const handlers: { [key: string]: NextApiHandler } = { onError: onError as any, callback, login, logout, profile }; + const backchannelLogout: NextApiHandler = (...args) => (backchannelLogoutHandler || handleBackchannelLogout)(...args); + const handlers: { [key: string]: NextApiHandler } = { + onError: onError as any, + callback, + login, + logout, + profile, + 'backchannel-logout:': backchannelLogout + }; global.handleAuth = handleAuth.bind(null, handlers); global.getSession = getSession; global.touchSession = touchSession; diff --git a/tests/handlers/backchannel-logout-page-router.test.ts b/tests/handlers/backchannel-logout-page-router.test.ts new file mode 100644 index 000000000..2ee1723d1 --- /dev/null +++ b/tests/handlers/backchannel-logout-page-router.test.ts @@ -0,0 +1,62 @@ +import { withoutApi } from '../fixtures/default-settings'; +import { post, Store } from '../auth0-session/fixtures/helpers'; + +import { setup, teardown } from '../fixtures/setup'; +import { makeLogoutToken } from '../auth0-session/fixtures/cert'; + +describe('backchannel-logout handler (page router)', () => { + afterEach(teardown); + + test('should 404 when backchannel logout is disabled', async () => { + const baseUrl = await setup(withoutApi); + + await expect(post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: '' })).rejects.toThrow( + 'Not Found' + ); + }); + + test('should error when misconfigured', async () => { + const baseUrl = await setup({ ...withoutApi, backchannelLogout: true }); + + await expect(post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: '' })).rejects.toThrow( + 'Bad Request' + ); + }); + + test('should error when misconfigured', async () => { + const baseUrl = await setup({ ...withoutApi, backchannelLogout: true }); + + await expect(post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: '' })).rejects.toThrow( + 'Bad Request' + ); + }); + + test('should error when an invalid logout token is provided', async () => { + const baseUrl = await setup({ ...withoutApi, backchannelLogout: { store: new Store() } }); + + await expect( + post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: 'logout_token=foo' }) + ).rejects.toThrow('Bad Request'); + }); + + test('should succeed when a valid logout token is provided', async () => { + const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo' }); + const baseUrl = await setup({ ...withoutApi, backchannelLogout: { store: new Store() } }); + + await expect( + post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: `logout_token=${logoutToken}` }) + ).resolves.toMatchObject({ res: { statusCode: 204 } }); + }); + + test('should save tokens into the store when a valid logout token is provided', async () => { + const store = new Store(); + const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo', sub: 'bar' }); + const baseUrl = await setup({ ...withoutApi, backchannelLogout: { store } }); + + await expect( + post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: `logout_token=${logoutToken}` }) + ).resolves.toMatchObject({ res: { statusCode: 204 } }); + await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ data: {} }); + await expect(store.get('sub|__test_client_id__|bar')).resolves.toMatchObject({ data: {} }); + }); +}); diff --git a/tests/handlers/backchannel-logout.test.ts b/tests/handlers/backchannel-logout.test.ts new file mode 100644 index 000000000..d843f157e --- /dev/null +++ b/tests/handlers/backchannel-logout.test.ts @@ -0,0 +1,91 @@ +/** + * **REMOVE-TO-TEST-ON-EDGE**@jest-environment @edge-runtime/jest-environment + */ +import { getResponse, mockFetch } from '../fixtures/app-router-helpers'; +import { Store } from '../auth0-session/fixtures/helpers'; +import { makeLogoutToken } from '../auth0-session/fixtures/cert'; + +describe('backchannel-logout handler (app router)', () => { + beforeEach(mockFetch); + + test('should 404 when backchannel logout is disabled', async () => { + await expect( + getResponse({ url: '/api/auth/backchannel-logout', reqInit: { method: 'post' } }) + ).resolves.toMatchObject({ status: 404 }); + }); + + test('should error when misconfigured', async () => { + const res = await getResponse({ + config: { backchannelLogout: true }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post' } + }); + await expect(res.json()).resolves.toEqual({ + error: 'unknown_error', + error_description: + // eslint-disable-next-line max-len + 'Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions).' + }); + }); + + test('should error when no logout token is provided', async () => { + const res = await getResponse({ + config: { backchannelLogout: { store: new Store() } }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post' } + }); + await expect(res.json()).resolves.toEqual({ + error: 'invalid_request', + error_description: 'Missing Logout Token' + }); + }); + + test('should error when an invalid logout token is provided', async () => { + const res = await getResponse({ + config: { backchannelLogout: { store: new Store() } }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post', body: 'logout_token=foo' } + }); + await expect(res.json()).resolves.toEqual({ + error: 'invalid_request', + error_description: 'Invalid Compact JWS' + }); + }); + + test('should succeed when a valid logout token is provided', async () => { + const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo' }); + const res = await getResponse({ + config: { backchannelLogout: { store: new Store() } }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post', body: `logout_token=${logoutToken}` } + }); + expect(res.status).toBe(204); + }); + + test('should fail when logout token validation fails', async () => { + const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo', events: null }); + const res = await getResponse({ + config: { backchannelLogout: { store: new Store() } }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post', body: `logout_token=${logoutToken}` } + }); + await expect(res.json()).resolves.toEqual({ + error: 'invalid_request', + error_description: '"events" claim must be an object' + }); + }); + + test('should save tokens into the store when a valid logout token is provided', async () => { + const store = new Store(); + const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo', sub: 'bar' }); + await expect( + getResponse({ + config: { backchannelLogout: { store } }, + url: '/api/auth/backchannel-logout', + reqInit: { method: 'post', body: `logout_token=${logoutToken}` } + }) + ).resolves.toMatchObject({ status: 204 }); + await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ data: {} }); + await expect(store.get('sub|__test_client_id__|bar')).resolves.toMatchObject({ data: {} }); + }); +}); From 65c622b7ff75b17d63e6c6409e0aef5be1fc67e2 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Thu, 30 Nov 2023 16:50:01 +0000 Subject: [PATCH 5/8] Edge doesn't support querystring --- src/auth0-session/handlers/backchannel-logout.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/auth0-session/handlers/backchannel-logout.ts b/src/auth0-session/handlers/backchannel-logout.ts index cd0eb3846..873cc826b 100644 --- a/src/auth0-session/handlers/backchannel-logout.ts +++ b/src/auth0-session/handlers/backchannel-logout.ts @@ -2,7 +2,6 @@ import { Auth0Request, Auth0Response } from '../http'; import { Config, GetConfig } from '../config'; import { GetClient } from '../client/abstract-client'; import getLogoutTokenVerifier from '../utils/logout-token-verifier'; -import * as querystring from 'querystring'; import { BackchannelLogoutError } from '../utils/errors'; import { JWTPayload } from 'jose'; @@ -26,16 +25,8 @@ export default function backchannelLogoutHandlerFactory( const config = await getConfigFn(req); const client = await getClient(config); res.setHeader('cache-control', 'no-store'); - let body = await req.getBody(); - if (typeof body === 'string') { - try { - body = querystring.parse(body) as Record; - /* c8 ignore next 3 */ - } catch (e) { - body = {}; - } - } - const logoutToken = (body as Record).logout_token; + const body = new URLSearchParams(await req.getBody()); + const logoutToken = body.get('logout_token'); if (!logoutToken) { throw new BackchannelLogoutError('invalid_request', 'Missing Logout Token'); } From 6590b7f6ae6afc1d36c96b7879adda529cdaae9a Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 1 Dec 2023 13:57:11 +0000 Subject: [PATCH 6/8] Fix failed profile req not setting cookie --- src/handlers/profile.ts | 4 +++- tests/handlers/profile.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/handlers/profile.ts b/src/handlers/profile.ts index 48a09dc74..3da6c614e 100644 --- a/src/handlers/profile.ts +++ b/src/handlers/profile.ts @@ -154,7 +154,9 @@ const appRouteHandlerFactory: ( const res = new NextResponse(); if (!(await sessionCache.isAuthenticated(req, res))) { - return new Response(null, { status: 204 }); + const emptyRes = new NextResponse(null, { status: 204 }); + res.headers.forEach((val, key) => emptyRes.headers.set(key, val)); + return emptyRes; } const session = (await sessionCache.get(req, res)) as Session; diff --git a/tests/handlers/profile.test.ts b/tests/handlers/profile.test.ts index d253510a4..feefeea82 100644 --- a/tests/handlers/profile.test.ts +++ b/tests/handlers/profile.test.ts @@ -10,9 +10,11 @@ import { getResponse, login as appRouterLogin, getSession as appRouterGetSession, - mockFetch + mockFetch, + initAuth0 } from '../fixtures/app-router-helpers'; import { NextRequest } from 'next/server'; +import { Store } from '../auth0-session/fixtures/helpers'; describe('profile handler (app router)', () => { beforeEach(mockFetch); @@ -178,4 +180,25 @@ describe('profile handler (app router)', () => { }) ).resolves.toMatchObject({ status: 500, statusText: expect.stringMatching(/some validation error/) }); }); + + test('should clear the cookie after back-channel logout', async () => { + const store = new Store(); + const auth0Instance = initAuth0({ ...withApi, backchannelLogout: { store } }); + const loginRes = await appRouterLogin({ auth0Instance }); + const res = await getResponse({ + auth0Instance, + url: '/api/auth/me', + cookies: { appSession: loginRes.cookies.get('appSession').value } + }); + expect(res.status).toBe(200); + const user = await res.json(); + await store.set(`sub|${withApi.clientID}|${user.sub}`, {}); + const res2 = await getResponse({ + auth0Instance, + url: '/api/auth/me', + cookies: { appSession: loginRes.cookies.get('appSession').value } + }); + expect(res2.status).toBe(204); + expect(res2.headers.get('Set-cookie')).toMatch(/^appSession=;.*/); + }); }); From 52f2675da49a7a55ab5e86987762bb665654d26a Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 1 Dec 2023 14:42:54 +0000 Subject: [PATCH 7/8] Ensure no-store --- src/handlers/backchannel-logout.ts | 3 ++- src/http/auth0-next-response.ts | 4 ++++ tests/handlers/backchannel-logout-page-router.test.ts | 9 ++++++--- tests/handlers/backchannel-logout.test.ts | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/handlers/backchannel-logout.ts b/src/handlers/backchannel-logout.ts index 0cea011b4..e0c64e3be 100644 --- a/src/handlers/backchannel-logout.ts +++ b/src/handlers/backchannel-logout.ts @@ -45,7 +45,7 @@ const appRouteHandlerFactory: ( error: e.code || 'unknown_error', error_description: e.description || e.message }, - { status: 400 } + { status: 400, headers: { 'cache-control': 'no-store' } } ); } }; @@ -63,6 +63,7 @@ const pageRouteHandlerFactory: ( } return await handler(auth0Req, new Auth0NextApiResponse(res)); } catch (e) { + res.setHeader('cache-control', 'no-store'); res.status(400).json({ error: e.code || 'unknown_error', error_description: e.description || e.message diff --git a/src/http/auth0-next-response.ts b/src/http/auth0-next-response.ts index ddfec63c5..77647d14a 100644 --- a/src/http/auth0-next-response.ts +++ b/src/http/auth0-next-response.ts @@ -33,6 +33,10 @@ export default class Auth0NextResponse extends Auth0Response { } public send204() { + const oldRes = this.res; this.res = new NextResponse(null, { status: 204 }); + oldRes.headers.forEach((value, key) => { + this.res.headers.set(key, value); + }); } } diff --git a/tests/handlers/backchannel-logout-page-router.test.ts b/tests/handlers/backchannel-logout-page-router.test.ts index 2ee1723d1..b71cae6c7 100644 --- a/tests/handlers/backchannel-logout-page-router.test.ts +++ b/tests/handlers/backchannel-logout-page-router.test.ts @@ -53,9 +53,12 @@ describe('backchannel-logout handler (page router)', () => { const logoutToken = await makeLogoutToken({ iss: 'https://acme.auth0.local/', sid: 'foo', sub: 'bar' }); const baseUrl = await setup({ ...withoutApi, backchannelLogout: { store } }); - await expect( - post(baseUrl, '/api/auth/backchannel-logout', { fullResponse: true, body: `logout_token=${logoutToken}` }) - ).resolves.toMatchObject({ res: { statusCode: 204 } }); + const { res } = await post(baseUrl, '/api/auth/backchannel-logout', { + fullResponse: true, + body: `logout_token=${logoutToken}` + }); + expect(res.statusCode).toBe(204); + expect(res.headers['cache-control']).toBe('no-store'); await expect(store.get('sid|__test_client_id__|foo')).resolves.toMatchObject({ data: {} }); await expect(store.get('sub|__test_client_id__|bar')).resolves.toMatchObject({ data: {} }); }); diff --git a/tests/handlers/backchannel-logout.test.ts b/tests/handlers/backchannel-logout.test.ts index d843f157e..e19f84e32 100644 --- a/tests/handlers/backchannel-logout.test.ts +++ b/tests/handlers/backchannel-logout.test.ts @@ -60,6 +60,7 @@ describe('backchannel-logout handler (app router)', () => { reqInit: { method: 'post', body: `logout_token=${logoutToken}` } }); expect(res.status).toBe(204); + expect(res.headers.get('cache-control')).toBe('no-store'); }); test('should fail when logout token validation fails', async () => { @@ -73,6 +74,7 @@ describe('backchannel-logout handler (app router)', () => { error: 'invalid_request', error_description: '"events" claim must be an object' }); + expect(res.headers.get('cache-control')).toBe('no-store'); }); test('should save tokens into the store when a valid logout token is provided', async () => { From 470ad8e2ab646e33fd8284c055fa7854c6012b93 Mon Sep 17 00:00:00 2001 From: Adam Mcgrath Date: Fri, 1 Dec 2023 15:57:39 +0000 Subject: [PATCH 8/8] Add example --- EXAMPLES.md | 75 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 35fdeddfb..9b9155861 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -11,6 +11,7 @@ - [Add a signup handler](#add-a-signup-handler) - [Use with Base Path and Internationalized Routing](#use-with-base-path-and-internationalized-routing) - [Use a custom session store](#use-a-custom-session-store) +- [Back-Channel Logout](#back-channel-logout) See also the [example app](./example-app). @@ -505,25 +506,73 @@ class Store implements SessionStore { } } -let auth0; - -export default () => { - if (!auth0) { - auth0 = initAuth0({ - session: { - store: new Store() - } - }); +export default initAuth0({ + session: { + store: new Store() } - return auth0; -}; +}); ``` Then use your instance wherever you use the server methods of the SDK. ```ts // /pages/api/auth/[auth0].js -import getAuth0 from '../../../lib/auth0'; +import auth0 from '../../../lib/auth0'; + +export default auth0.handleAuth(); +``` + +### Back-Channel Logout + +Back-Channel Logout requires a session store, so you'll need to create your own instance of the SDK in code and pass an instance of your session store to the SDK's configuration: + +```js +// lib/auth0.ts +import { initAuth0 } from '@auth0/nextjs-auth0'; -export default getAuth0().handleAuth(); +export default initAuth0({ + backChannelLogout: { + store: new Store() // See "Use a custom session store" for how to define a Store class. + } +}); ``` + +If you are already using a session store, you can just reuse that: + +```js +// lib/auth0.ts +import { initAuth0 } from '@auth0/nextjs-auth0'; + +export default initAuth0({ + session: { + store: new Store() + }, + backchannelLogout: true +}); +``` + +Once you've enabled the `backchannelLogout` option, `handleAuth` will create a `/api/auth/backchannel-logout` POST handler. + +#### Pages Router + +```ts +// /pages/api/auth/[auth0].js +import auth0 from '../../../lib/auth0'; + +export default auth0.handleAuth(); +``` + +#### App Router + +```ts +// /app/api/auth/[auth0]/route.js +import auth0 from '../../../lib/auth0'; + +const handler = auth0.handleAuth(); + +// For Back-Channel Logout you need to export a GET and a POST handler. +export { handler as GET, handler as POST }; +``` + +Then configure your tenant following [these instructions](https://auth0.com/docs/authenticate/login/logout/back-channel-logout/configure-back-channel-logout#configure-auth0). +Your "OpenID Connect Back-Channel Logout URI" will be `{YOUR_AUTH0_BASE_URL}/api/auth/backchannel-logout`.