94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
/**
|
|
* 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<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 };
|
|
}
|
|
|
|
/**
|
|
* 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<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 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<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(APIErrorCode.UNAUTHORIZED, 401, "Invalid token");
|
|
}
|
|
return payload.userId;
|
|
}
|