/** * 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 { 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 { 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 { 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; }