114 lines
3.1 KiB
TypeScript
114 lines
3.1 KiB
TypeScript
/**
|
|
* 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<R> = RouteParams<R>,
|
|
> = RouterContext<R, P, AuthState>;
|
|
|
|
/**
|
|
* 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<R extends string>(
|
|
ctx: AuthContext<R>,
|
|
next: Next,
|
|
): Promise<void> {
|
|
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<R extends string>(
|
|
ctx: AuthContext<R>,
|
|
next: Next,
|
|
): Promise<void> {
|
|
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,
|
|
): <R extends string>(ctx: AuthContext<R>, next: Next) => Promise<void> {
|
|
return async <R extends string>(
|
|
ctx: AuthContext<R>,
|
|
next: Next,
|
|
): Promise<void> => {
|
|
if (!ctx.state.user || !can(ctx.state.user, permission)) {
|
|
throw new APIException(
|
|
APIErrorCode.UNAUTHORIZED,
|
|
403,
|
|
"Insufficient permissions",
|
|
);
|
|
}
|
|
await next();
|
|
};
|
|
}
|