import type { Context } from "@oak/oak"; import { verifyJWT, verifyMediaToken } from "./jwt.ts"; import { APIErrorCode, APIException, type AuthPayload, type Role, } from "../model/interfaces.ts"; import { db } from "../model/db.ts"; /** * Checks a signature-verified token against the live user row: the user must * still exist, and the token must not predate their last password change * (`users.tokens_valid_after`). Returns the live role, or null to refuse. * * `iat` has one-second resolution, so a token issued in the same second as the * change survives it; one issued after it (the fresh one the change hands back) * always does. */ function checkTokenOwner( userId: string, iat: number | undefined, ): { role: Role } | null { const row = db.prepare( `SELECT role, tokens_valid_after FROM users WHERE id = ?;`, ).get(userId) as | { role: Role; tokens_valid_after: string | null } | undefined; if (!row) return null; if (row.tokens_valid_after) { const cutoff = Math.floor( new Date(row.tokens_valid_after).getTime() / 1000, ); if ((iat ?? 0) < cutoff) return null; } return { role: row.role }; } /** * Verifies a session token end to end: signature, expiry, a user that still * exists and no password change since it was issued. The role comes from the * DB, not the token, since tokens last 7 days and roles change under them. */ export async function authenticate( token: string, ): Promise { 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 }; } /** * Resolves the `?token=` on a media URL to the viewer's user id. Takes a media * token, or a session token from a client that predates them. */ export async function authenticateMedia(token: string): Promise { const media = await verifyMediaToken(token); if (media) { return checkTokenOwner(media.userId, media.iat) ? media.userId : null; } return (await authenticate(token))?.userId ?? null; } /** * Extracts the userId from an optional Bearer token. * * Distinguishes *missing* credentials from *invalid* ones: * - No `Authorization: Bearer …` header (or a placeholder value such as the * `Bearer undefined` that the client emits when logged out) → anonymous (null). * - A real token that fails verification (expired / bad signature / revoked) → * throws 401, so a stale client session surfaces as an auth error it can react * to, instead of being silently downgraded to anonymous (which yields a * misleading 404 on private resources). */ export async function parseOptionalAuth(ctx: Context): Promise { 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(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); } return payload.userId; }