88 lines
3.3 KiB
TypeScript
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' };
|
|
}
|
|
}
|
|
}
|