Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
2.9 KiB
TypeScript
103 lines
2.9 KiB
TypeScript
/**
|
|
* Gates routes on a session token, and on a permission after it. The routers
|
|
* put these middleware before a handler; a route open to anonymous callers
|
|
* calls `parseOptionalAuth` (`lib/auth.ts`) instead.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import {
|
|
type Next,
|
|
type RouteParams,
|
|
type RouterContext,
|
|
type State,
|
|
} from "@oak/oak";
|
|
import { authenticate } from "../lib/auth.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";
|
|
|
|
/**
|
|
* Set on `ctx.state` by {@link authMiddleware} for the handlers after it.
|
|
* `user` carries the caller's live role, not the one in their token.
|
|
*/
|
|
export interface AuthState extends State {
|
|
user: AuthPayload;
|
|
}
|
|
|
|
/**
|
|
* The context type of a handler behind {@link authMiddleware}. Pass the route
|
|
* path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed.
|
|
*/
|
|
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
|
|
* payload in `ctx.state.user` for the rest of the route.
|
|
*
|
|
* @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(401, "Missing or invalid token");
|
|
}
|
|
|
|
const token = authHeader.substring(7);
|
|
const payload = await authenticate(token);
|
|
|
|
if (!payload) {
|
|
throw new APIException(401, "Invalid token");
|
|
}
|
|
|
|
ctx.state.user = payload;
|
|
|
|
await next();
|
|
}
|
|
|
|
/**
|
|
* Requires the caller to be an admin. Must run after {@link authMiddleware}.
|
|
* No route uses it: they gate on a permission with {@link requirePermission}.
|
|
*
|
|
* @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(403, "Admin access required");
|
|
}
|
|
|
|
await next();
|
|
}
|
|
|
|
/**
|
|
* Returns a middleware that requires the caller to hold `permission`. It must
|
|
* run after {@link authMiddleware}, as in
|
|
* `router.patch(path, authMiddleware, requirePermission("user:manage"), …)`.
|
|
* The returned middleware throws an {@link APIException} (403) when the caller
|
|
* lacks the permission.
|
|
*/
|
|
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(403, "Insufficient permissions");
|
|
}
|
|
await next();
|
|
};
|
|
}
|