mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
cc79f561e5
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral token service lived in the auth domain, so every controller that checked a session depended on the whole account module and its imports. They import no domain themselves; under src/nest/auth-core they are shared kernel like common/, every importer points there directly, and AtlasModule and NotificationsModule stop importing AuthModule, which they only did for the demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic domain edges, the largest cycle shrinks from 46 to 40 domains.
96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
import { Controller, Delete, Get, HttpCode, HttpException, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
|
|
import type { Request, Response } from 'express';
|
|
import {
|
|
userSessionRevokeParamsSchema,
|
|
type UserSessionListResponse,
|
|
type UserSessionRevokeOthersResponse,
|
|
type UserSessionRevokeParams,
|
|
type UserSessionRevokeResponse,
|
|
} from '@trek/shared';
|
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
|
import { clearAuthCookie } from '../common/cookie';
|
|
import { getClientIp } from '../audit/client-ip';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import { SessionsService } from '../sessions/sessions.service';
|
|
import { isDemoEmail } from '../common/demo';
|
|
import { readEnv } from '../../app-config';
|
|
import type { User } from '../../types';
|
|
import { CurrentUser } from '../auth-core/current-user.decorator';
|
|
import { currentSessionId } from '../auth-core/jwt-verify';
|
|
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
|
|
|
/**
|
|
* The signed-in user's own sessions: where they are signed in, and signing
|
|
* one or all the others out. Behind JwtAuthGuard like the other account
|
|
* routes, and scoped to the caller: another user's session id answers 404,
|
|
* exactly like one that does not exist.
|
|
*
|
|
* A request made with a token from before sessions were tracked sees no
|
|
* current session (`current_tracked: false`). Signing out the others then
|
|
* ends every tracked session, while that token itself runs on to its expiry.
|
|
*
|
|
* On a demo instance every visitor signs in as the one shared demo account,
|
|
* so its sessions are other people's browsers: the list shows the caller only
|
|
* their own session, and ending sessions is refused with the 403 the other
|
|
* account changes answer in demo mode.
|
|
*
|
|
* No MCP tool mirrors these routes: MCP exposes no account or session tools,
|
|
* and a session is a browser credential an assistant has no business ending.
|
|
*/
|
|
@Controller('api/auth/sessions')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class SessionsController {
|
|
constructor(
|
|
private readonly sessions: SessionsService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
@Get()
|
|
async list(@CurrentUser() user: User, @Req() req: Request): Promise<UserSessionListResponse> {
|
|
const current = currentSessionId(req);
|
|
const sessions = await this.sessions.list(user.id, current);
|
|
return {
|
|
sessions: isSharedDemoAccount(user) ? sessions.filter((session) => session.current) : sessions,
|
|
current_tracked: current !== undefined,
|
|
};
|
|
}
|
|
|
|
// Static sub-route before the `:id` one.
|
|
@Post('revoke-others')
|
|
@HttpCode(200)
|
|
async revokeOthers(@CurrentUser() user: User, @Req() req: Request): Promise<UserSessionRevokeOthersResponse> {
|
|
refuseForSharedDemoAccount(user);
|
|
const revoked = await this.sessions.revokeAll(user.id, currentSessionId(req));
|
|
await this.audit.writeAudit({ userId: user.id, action: 'user.sessions_revoke_others', ip: getClientIp(req), details: { revoked } });
|
|
return { success: true, revoked };
|
|
}
|
|
|
|
@Delete(':id')
|
|
async revoke(
|
|
@CurrentUser() user: User,
|
|
@Param(new ZodValidationPipe(userSessionRevokeParamsSchema)) params: UserSessionRevokeParams,
|
|
@Req() req: Request,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<UserSessionRevokeResponse> {
|
|
refuseForSharedDemoAccount(user);
|
|
if (!(await this.sessions.revoke(user.id, params.id))) {
|
|
throw new HttpException({ error: 'Session not found' }, 404);
|
|
}
|
|
// Ending the session this request came with is a logout: the cookie goes too.
|
|
if (params.id === currentSessionId(req)) clearAuthCookie(res, req);
|
|
await this.audit.writeAudit({ userId: user.id, action: 'user.session_revoke', ip: getClientIp(req), resource: params.id });
|
|
return { success: true };
|
|
}
|
|
}
|
|
|
|
/** The demo account every visitor of a demo instance shares. */
|
|
function isSharedDemoAccount(user: User): boolean {
|
|
return readEnv().demo.enabled && isDemoEmail(user.email);
|
|
}
|
|
|
|
function refuseForSharedDemoAccount(user: User): void {
|
|
if (isSharedDemoAccount(user)) {
|
|
throw new HttpException({ error: 'Sessions cannot be ended in demo mode.' }, 403);
|
|
}
|
|
}
|