/** * Middleware gating routes on authentication, the admin role, or a * permission. * * @module */ import { type Next, type RouteParams, type RouterContext, type State, } from "@oak/oak"; import { authenticate } from "../lib/auth.ts"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { AuthPayload } from "../model/auth.ts"; import { can } from "../lib/permissions.ts"; import type { Permission } from "../../shared/permissions.ts"; /** The request state once {@link authMiddleware} has run. */ export interface AuthState extends State { /** The authenticated caller, with their live role. */ user: AuthPayload; } /** * A router context whose state is guaranteed to carry the authenticated user * (populated by {@link authMiddleware}). Generic over the route path so * `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`. */ export type AuthContext< R extends string = string, P extends RouteParams = RouteParams, > = RouterContext; /** * Requires a valid session token in the `Authorization` header and puts its * caller in `ctx.state.user`. * * @throws {APIException} 401 when the token is missing or invalid. */ export async function authMiddleware( ctx: AuthContext, next: Next, ): Promise { const authHeader = ctx.request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { throw new APIException( APIErrorCode.UNAUTHORIZED, 401, "Missing or invalid token", ); } const token = authHeader.substring(7); // Also checks the user still exists and hasn't changed their password since // the token was issued, and takes the live role from the DB. const payload = await authenticate(token); if (!payload) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); } ctx.state.user = payload; await next(); } /** * Requires the caller to be an admin. Must run after {@link authMiddleware}. * * @throws {APIException} 403 when the caller isn't an admin. */ export async function adminOnlyMiddleware( ctx: AuthContext, next: Next, ): Promise { if (ctx.state.user.role !== "admin") { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Admin access required", ); } await next(); } /** * Returns a middleware requiring the caller to hold `permission`, which throws * an {@link APIException} (403) when they don't. Must run after * {@link authMiddleware}. Generic over the route path so it composes with * path-typed route handlers. */ export function requirePermission( permission: Permission, ): (ctx: AuthContext, next: Next) => Promise { return async ( ctx: AuthContext, next: Next, ): Promise => { if (!ctx.state.user || !can(ctx.state.user, permission)) { throw new APIException( APIErrorCode.UNAUTHORIZED, 403, "Insufficient permissions", ); } await next(); }; }