metis-desk/modules/identity/src/api/infrastructure/http/identity.routes.ts
2026-10-10 12:01:58 +02:00

299 lines
8.4 KiB
TypeScript

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<void> {
return new Promise((resolve, reject) =>
request.session.save((error) => (error ? reject(error) : resolve()))
);
}
async function signIn(request: Request, userId: string, authVersion: number): Promise<void> {
await new Promise<void>((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 = <T>(
path: string,
schema: z.ZodType<T>,
execute: (dto: T, request: Request, response: Response) => Promise<void>
) => {
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<void>((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);
}