import { Injectable, Logger } from '@nestjs/common'; import { createPublicKey, createVerify, type KeyObject } from 'node:crypto'; import { IamService, IamUnavailableError } from './iam.service'; /** The logout token was not a valid, current back-channel logout token. */ export class IamLogoutTokenError extends Error {} export interface LogoutSubject { /** The IAM session id (`sid`), when the token names one session. */ sessionId: string | null; /** The IAM user id (`sub`), when the token names a whole identity. */ subject: string | null; } const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'; /** * Verification of OpenID Connect Back-Channel Logout 1.0 tokens. * * The IAM calls this app when a session ends elsewhere (the user signed out, or * an administrator revoked the session). The request carries no credentials by * design — its authority is the token's RS256 signature, checked here against the * IAM's published keys — so every one of the spec's checks matters: * * - signature verifies against a key from the IAM's JWKS (keys are cached, and * an unknown `kid` refetches once so key rotation needs no restart); * - `iss` is exactly the configured issuer and `aud` contains this client id, * otherwise another tenant's token could sign out our admins; * - the `events` claim holds the back-channel logout event, so an ID token * cannot be replayed here; * - a `nonce` MUST NOT be present (that would make it an ID token); * - `iat`/`exp` are honoured with a small clock skew allowance. * * No new dependency: Node verifies RS256 and imports a JWK natively. */ @Injectable() export class IamLogoutService { private readonly logger = new Logger(IamLogoutService.name); /** kid -> public key, plus when the set was fetched. */ private keys = new Map(); private keysFetchedAt = 0; constructor(private readonly iam: IamService) {} /** Seconds of clock skew tolerated on `iat`/`exp`. */ private get leeway(): number { return 60; } async verify(logoutToken: string): Promise { const parts = logoutToken.split('.'); if (parts.length !== 3) throw new IamLogoutTokenError('logout token is malformed'); const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string]; const header = this.decodeJson(rawHeader, 'header'); const payload = this.decodeJson(rawPayload, 'payload'); if (header.alg !== 'RS256') { // Never trust the token's own word that it needs no signature. throw new IamLogoutTokenError(`unsupported logout token algorithm: ${String(header.alg)}`); } const key = await this.publicKey(typeof header.kid === 'string' ? header.kid : null); const verifier = createVerify('RSA-SHA256'); verifier.update(`${rawHeader}.${rawPayload}`); if (!verifier.verify(key, Buffer.from(rawSignature, 'base64url'))) { throw new IamLogoutTokenError('logout token signature does not verify'); } this.assertClaims(payload); const sessionId = typeof payload.sid === 'string' && payload.sid !== '' ? payload.sid : null; const subject = typeof payload.sub === 'string' && payload.sub !== '' ? payload.sub : null; if (!sessionId && !subject) { throw new IamLogoutTokenError('logout token names neither a session nor a subject'); } return { sessionId, subject }; } private assertClaims(payload: Record): void { if (payload.iss !== this.iam.issuer) { throw new IamLogoutTokenError('logout token was issued by another identity service'); } const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(this.iam.clientId)) { throw new IamLogoutTokenError('logout token is not addressed to this client'); } // A logout token carries the logout event; an ID token does not. Without this // check an ID token obtained during a normal login could end sessions. const events = payload.events as Record | undefined; if (!events || typeof events !== 'object' || !(BACKCHANNEL_LOGOUT_EVENT in events)) { throw new IamLogoutTokenError('logout token does not carry the back-channel logout event'); } if ('nonce' in payload) { throw new IamLogoutTokenError('a logout token must not carry a nonce'); } const now = Math.floor(Date.now() / 1000); if (typeof payload.exp === 'number' && payload.exp + this.leeway < now) { throw new IamLogoutTokenError('logout token has expired'); } if (typeof payload.iat === 'number' && payload.iat - this.leeway > now) { throw new IamLogoutTokenError('logout token is not valid yet'); } } /** * The signing key for this `kid`. An unknown kid triggers one refetch, so a key * rotation at the IAM takes effect without restarting this service. */ private async publicKey(kid: string | null): Promise { const cacheAgeSeconds = (Date.now() - this.keysFetchedAt) / 1000; const stale = cacheAgeSeconds > 24 * 3600; const cached = kid ? this.keys.get(kid) : [...this.keys.values()][0]; if (cached && !stale) return cached; await this.loadKeys(false); let key = kid ? this.keys.get(kid) : [...this.keys.values()][0]; if (!key && kid) { await this.loadKeys(true); key = this.keys.get(kid); } if (!key) throw new IamLogoutTokenError('no matching signing key at the identity service'); return key; } private async loadKeys(bypassCache: boolean): Promise { const uri = bypassCache ? `${this.iam.issuer}/.well-known/jwks.json?_=${Date.now()}` : `${this.iam.issuer}/.well-known/jwks.json`; let response: Response; try { response = await fetch(uri, { headers: { accept: 'application/json', ...(bypassCache ? { 'cache-control': 'no-cache', pragma: 'no-cache' } : {}), }, }); } catch (error) { throw new IamUnavailableError(`could not reach the identity service: ${(error as Error).message}`); } if (!response.ok) { throw new IamUnavailableError(`the identity service returned ${response.status} for its keys`); } const body = (await response.json()) as { keys?: Array> }; const keys = new Map(); for (const jwk of body.keys ?? []) { if (jwk.kty !== 'RSA' || typeof jwk.kid !== 'string') continue; try { keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' })); } catch (error) { this.logger.warn(`skipping an unusable key from the identity service: ${(error as Error).message}`); } } if (keys.size === 0) throw new IamUnavailableError('the identity service published no usable keys'); this.keys = keys; this.keysFetchedAt = Date.now(); } private decodeJson(segment: string, what: string): Record { try { return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record; } catch { throw new IamLogoutTokenError(`logout token ${what} is not readable`); } } }