All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
96 lines
2.5 KiB
TypeScript
96 lines
2.5 KiB
TypeScript
import {
|
|
type Next,
|
|
type RouteParams,
|
|
type RouterContext,
|
|
type State,
|
|
} from "@oak/oak";
|
|
import { verifyJWT } from "../lib/jwt.ts";
|
|
import {
|
|
APIErrorCode,
|
|
APIException,
|
|
type AuthPayload,
|
|
} from "../model/interfaces.ts";
|
|
import { getUserById } from "../services/user-service.ts";
|
|
import { can, type Permission } from "../lib/permissions.ts";
|
|
|
|
export interface AuthState extends State {
|
|
user: AuthPayload;
|
|
}
|
|
|
|
// A router context whose state is guaranteed to carry the authenticated user
|
|
// (populated by 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>;
|
|
|
|
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);
|
|
const payload = await verifyJWT(token);
|
|
|
|
if (!payload) {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
|
}
|
|
|
|
let user;
|
|
try {
|
|
user = getUserById(payload.userId);
|
|
} catch {
|
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found");
|
|
}
|
|
|
|
// Trust the live role from the DB, not the role baked into the JWT at login.
|
|
// Tokens last 7 days, so a role change (e.g. promotion to admin or a demotion)
|
|
// would otherwise not take effect until the user re-logs in.
|
|
ctx.state.user = { ...payload, role: user.role };
|
|
|
|
await next();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// Gates a route on a named permission. Must run after authMiddleware. Generic
|
|
// over the route path so it composes with path-typed route handlers.
|
|
export function requirePermission(permission: Permission) {
|
|
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();
|
|
};
|
|
}
|