import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common'; import { Public } from '../auth/guards'; import { AdminSessionService } from '../sessions/admin-session.service'; import { AppSessionService } from '../sessions/app-session.service'; import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service'; import { IamService } from './iam.service'; /** * Back-channel logout receiver (OpenID Connect Back-Channel Logout 1.0). * * Register this path as the client's `backchannel_logout_uri` in the IAM: * * https:///api/iam/backchannel-logout * * The endpoint is public because it carries no credentials by design — its * authority is the logout token's signature, verified against the IAM's keys. It * always answers 200, as the spec asks, so a rejected token does not make the IAM * retry forever; the reason is logged here instead. * * Nothing in the UI changes: this is how a central "sign out everywhere" or an * administrator revoking a session actually ends a session in this app. */ @Controller('iam') export class IamController { private readonly logger = new Logger(IamController.name); constructor( private readonly iam: IamService, private readonly logout: IamLogoutService, private readonly sessions: AdminSessionService, private readonly appSessions: AppSessionService, ) {} @Public() @Post('backchannel-logout') @HttpCode(200) async backchannelLogout(@Body() body: Record) { if (!this.iam.enabled) { // Not configured: nothing to act on, and no information to give away. return { status: 'ignored' }; } const token = typeof body?.logout_token === 'string' ? body.logout_token : ''; if (!token) { this.logger.warn('back-channel logout called without a logout token'); return { status: 'ignored' }; } try { const { sessionId, subject } = await this.logout.verify(token); // `sid` names one session; a token with only `sub` means every session of // that identity — matching the spec's two forms. // // Both token families are ended: the admin panel AND the app (customer / // sales expert). Leaving the app out would make a central sign-out look // like it worked while the phone in the user's pocket stayed signed in. const [admin, app] = sessionId ? await Promise.all([ this.sessions.revokeByIamSession(sessionId), this.appSessions.revokeByIamSession(sessionId), ]) : await Promise.all([ this.sessions.revokeByIamSubject(subject as string), this.appSessions.revokeByIamSubject(subject as string), ]); const revoked = admin + app; this.logger.log( `back-channel logout accepted (${sessionId ? `sid=${sessionId}` : `sub=${subject}`}): ` + `${admin} admin + ${app} app session(s) revoked`, ); return { status: 'ok', revoked }; } catch (error) { // A bad token is a security event worth logging, not a 4xx worth retrying. const detail = (error as Error).message; if (error instanceof IamLogoutTokenError) { this.logger.warn(`rejected a back-channel logout token: ${detail}`); } else { this.logger.error(`back-channel logout could not be processed: ${detail}`); } return { status: 'ignored' }; } } }