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>
88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
/**
|
|
* Checks session and media tokens against the live user row, not just their
|
|
* signature. The auth middleware, the WebSocket route, the media routes and the
|
|
* routes open to anonymous callers all authenticate through it.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { Context } from "@oak/oak";
|
|
import { verifyJWT, verifyMediaToken } from "./jwt.ts";
|
|
import { APIException } from "./api-exception.ts";
|
|
import type { AuthPayload } from "../model/auth.ts";
|
|
import type { Role } from "../../shared/user.ts";
|
|
import { getSessionState } from "../services/user-service.ts";
|
|
|
|
/**
|
|
* Returns the live role of a verified token's user, or null when the user is
|
|
* gone or the token predates their last password change
|
|
* (`users.tokens_valid_after`). `iat` counts whole seconds, so a token issued
|
|
* in the same second as the change survives it.
|
|
*/
|
|
function checkTokenOwner(
|
|
userId: string,
|
|
iat: number | undefined,
|
|
): { role: Role } | null {
|
|
const state = getSessionState(userId);
|
|
if (!state) return null;
|
|
if (state.tokensValidAfter) {
|
|
const cutoff = Math.floor(
|
|
new Date(state.tokensValidAfter).getTime() / 1000,
|
|
);
|
|
if ((iat ?? 0) < cutoff) return null;
|
|
}
|
|
return { role: state.role };
|
|
}
|
|
|
|
/**
|
|
* Returns the payload of a valid session token, or null. Besides the signature
|
|
* and expiry, it refuses a token whose user is gone or which predates a
|
|
* password change. The returned role comes from the database, not the token,
|
|
* since a token lasts 7 days and a role can change under it.
|
|
*/
|
|
export async function authenticate(
|
|
token: string,
|
|
): Promise<AuthPayload | null> {
|
|
const payload = await verifyJWT(token);
|
|
if (!payload) return null;
|
|
const live = checkTokenOwner(payload.userId, payload.iat);
|
|
if (!live) return null;
|
|
return { ...payload, role: live.role };
|
|
}
|
|
|
|
/**
|
|
* Returns the user id behind the `?token=` of a file, thumbnail or playlist
|
|
* cover URL, or null when it isn't valid. A session token is accepted too, for
|
|
* clients from before media tokens.
|
|
*/
|
|
export async function authenticateMedia(token: string): Promise<string | null> {
|
|
const media = await verifyMediaToken(token);
|
|
if (media) {
|
|
return checkTokenOwner(media.userId, media.iat) ? media.userId : null;
|
|
}
|
|
return (await authenticate(token))?.userId ?? null;
|
|
}
|
|
|
|
/**
|
|
* Returns the caller's user id on a route open to anonymous callers, or null
|
|
* when the request has no token. A placeholder such as `Bearer undefined`
|
|
* counts as no token. An invalid token throws instead of reading as anonymous,
|
|
* so that a stale session gets a 401 the client signs out on, rather than a
|
|
* misleading 404 on a private resource.
|
|
*
|
|
* @throws {APIException} 401 when a token is present but invalid.
|
|
*/
|
|
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
|
const authHeader = ctx.request.headers.get("Authorization");
|
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
|
|
const token = authHeader.substring(7).trim();
|
|
if (!token || token === "undefined" || token === "null") return null;
|
|
|
|
const payload = await authenticate(token);
|
|
if (!payload) {
|
|
throw new APIException(401, "Invalid token");
|
|
}
|
|
return payload.userId;
|
|
}
|