import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import type { Application, Request, Response } from "express"; import { Router } from "express"; import { rateLimit } from "express-rate-limit"; import type { z } from "zod/v4"; import type { SessionResponseDTO } from "../../../common"; import { BootstrapAdminRequestSchema, ChangePasswordRequestSchema, CreateUserRequestSchema, LoginRequestSchema, ResetPasswordRequestSchema, UpdateUserRequestSchema, UserParamsRequestSchema, } from "../../../common"; import type { IdentityUseCases } from "../../application/identity.use-cases"; import { buildUserResponse } from "../../application/user-response.builder"; import type { IdentityError } from "../../domain/identity-error"; import type { AuthenticatedSession } from "./session-types"; function sendError(response: Response, code: string, status: number): void { response .status(status) .json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() }); } function businessError(response: Response, error: IdentityError): void { const statuses = { INVALID_REQUEST: 400, INVALID_CREDENTIALS: 401, UNAUTHENTICATED: 401, FORBIDDEN: 403, SETUP_CLOSED: 409, EMAIL_EXISTS: 409, USER_NOT_FOUND: 404, ADMIN_PROTECTED: 409, }; sendError(response, error.code, statuses[error.code]); } function getContext(request: Request): AuthenticatedSession | undefined { const { userId, authVersion } = request.session; return userId && authVersion !== undefined ? { userId, authVersion } : undefined; } function saveSession(request: Request): Promise { return new Promise((resolve, reject) => request.session.save((error) => (error ? reject(error) : resolve())) ); } async function signIn(request: Request, userId: string, authVersion: number): Promise { await new Promise((resolve, reject) => request.session.regenerate((error) => (error ? reject(error) : resolve())) ); request.session.userId = userId; request.session.authVersion = authVersion; request.session.csrfToken = randomBytes(32).toString("hex"); await saveSession(request); } export function registerIdentityRoutes( app: Application, useCases: IdentityUseCases, origin: string ): void { const router = Router(); router.get("/auth/session", (request, response, next) => { const handle = async () => { request.session.csrfToken ??= randomBytes(32).toString("hex"); const user = await useCases.session(getContext(request)); if (!user) { request.session.userId = undefined; request.session.authVersion = undefined; } const snapshot: SessionResponseDTO = { user: user ? buildUserResponse(user) : null, setupRequired: await useCases.setupRequired(), csrfToken: request.session.csrfToken, }; await saveSession(request); response.json(snapshot); }; handle().catch(next); }); router.use((request, response, next) => { if (request.method === "GET") { next(); return; } const token = request.get("x-csrf-token"); const expected = request.session.csrfToken; if ( request.get("origin") !== origin || !token || !expected || !/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(token), Buffer.from(expected)) ) { sendError(response, "FORBIDDEN", 403); return; } next(); }); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false, skipSuccessfulRequests: true, handler: (_request, response) => sendError(response, "TOO_MANY_REQUESTS", 429), }); router.use(["/auth/login", "/auth/bootstrap"], authLimiter); const registerBody = ( path: string, schema: z.ZodType, execute: (dto: T, request: Request, response: Response) => Promise ) => { router.post(path, (request, response, next) => { const parsed = schema.safeParse(request.body); if (!parsed.success) { sendError(response, "INVALID_REQUEST", 400); return; } execute(parsed.data, request, response).catch(next); }); }; registerBody("/auth/bootstrap", BootstrapAdminRequestSchema, async (dto, request, response) => { const result = await useCases.bootstrap(dto); if (!result.isSuccess) { businessError(response, result.error); return; } await signIn(request, result.data.id.toPrimitive(), result.data.authVersion); response.status(201).json({ user: buildUserResponse(result.data) }); }); registerBody("/auth/login", LoginRequestSchema, async (dto, request, response) => { const result = await useCases.login(dto); if (!result.isSuccess) { businessError(response, result.error); return; } await signIn(request, result.data.id.toPrimitive(), result.data.authVersion); response.json({ user: buildUserResponse(result.data) }); }); registerBody( "/auth/logout", BootstrapAdminRequestSchema.pick({}), async (_dto, request, response) => { await new Promise((resolve, reject) => request.session.destroy((error) => (error ? reject(error) : resolve())) ); response.clearCookie("metis.sid", { httpOnly: true, sameSite: "strict", path: "/" }); response.json({ success: true }); } ); registerBody( "/auth/change-password", ChangePasswordRequestSchema, async (dto, request, response) => { const context = getContext(request); if (!context) { sendError(response, "UNAUTHENTICATED", 401); return; } const result = await useCases.changePassword(dto, context); if (!result.isSuccess) { businessError(response, result.error); return; } await signIn(request, result.data.id.toPrimitive(), result.data.authVersion); response.json({ user: buildUserResponse(result.data) }); } ); router.use("/users", (request, response, next) => { const handle = async () => { const user = await useCases.session(getContext(request)); if (!user) { sendError(response, "UNAUTHENTICATED", 401); return; } if (!user.isInitialAdmin) { sendError(response, "FORBIDDEN", 403); return; } next(); }; handle().catch(next); }); router.get("/users", (request, response, next) => { const context = getContext(request); if (!context) { sendError(response, "UNAUTHENTICATED", 401); return; } useCases .list(context) .then((result) => { if (!result.isSuccess) { businessError(response, result.error); return; } response.json({ users: result.data.map(buildUserResponse) }); }) .catch(next); }); registerBody("/users", CreateUserRequestSchema, async (dto, request, response) => { const context = getContext(request); if (!context) { sendError(response, "UNAUTHENTICATED", 401); return; } const result = await useCases.create(dto, context); if (!result.isSuccess) { businessError(response, result.error); return; } response.status(201).json({ user: buildUserResponse(result.data) }); }); registerBody("/users/:id/update", UpdateUserRequestSchema, async (dto, request, response) => { const context = getContext(request); const params = UserParamsRequestSchema.safeParse(request.params); if (!(context && params.success)) { sendError(response, "INVALID_REQUEST", 400); return; } const result = await useCases.update(params.data.id, dto, context); if (!result.isSuccess) { businessError(response, result.error); return; } response.json({ user: buildUserResponse(result.data) }); }); registerBody( "/users/:id/reset-password", ResetPasswordRequestSchema, async (dto, request, response) => { const context = getContext(request); const params = UserParamsRequestSchema.safeParse(request.params); if (!(context && params.success)) { sendError(response, "INVALID_REQUEST", 400); return; } const result = await useCases.resetPassword(params.data.id, dto, context); if (!result.isSuccess) { businessError(response, result.error); return; } response.json({ user: buildUserResponse(result.data) }); } ); app.use("/api", router); }