Files
hedena_gold/apps/api/src/modules/iam/iam-logout.service.ts
T
s.esmaeili ea86636027 Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
2026-10-15 17:19:10 +03:30

181 lines
7.0 KiB
TypeScript

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<string, KeyObject>();
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<LogoutSubject> {
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<string, unknown>): 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<string, unknown> | 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<KeyObject> {
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<void> {
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<Record<string, unknown>> };
const keys = new Map<string, KeyObject>();
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<string, unknown> {
try {
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>;
} catch {
throw new IamLogoutTokenError(`logout token ${what} is not readable`);
}
}
}