/** * Authenticates the tokens a request carries: session tokens in the * `Authorization` header and media tokens in a media URL's query string. * * @module */ import type { Context } from "@oak/oak"; import { verifyJWT, verifyMediaToken } from "./jwt.ts"; import { APIErrorCode } from "../../shared/api.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"; /** * 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 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 }; } /** * 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; } /** * Returns the user id of an optional Bearer token, or null for an anonymous * request. * * 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). * * @throws {APIException} 401 when a token is present but fails verification. */ 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; }