Files
tehran_taj/apps/api/src/modules/iam/iam.controller.ts
T
2026-09-25 19:04:48 +03:30

88 lines
3.3 KiB
TypeScript

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-host>/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<string, unknown>) {
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' };
}
}
}