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