lib/web-auth.ts
3.1 KB · sha256:e836abbf37d7a470f40ccc9ca57e66603db6368b5aeed750666d802901ec156a
import { hasPermission } from "../models/Player";
interface TokenEntry {
uuid: string;
name: string;
createdAt: number;
expiresAt: number;
}
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const tokens = new Map<string, TokenEntry>();
const byUuid = new Map<string, string>();
function newToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function issueToken(uuid: string, name: string): string {
const existing = byUuid.get(uuid.toLowerCase());
if (existing) tokens.delete(existing);
const token = newToken();
tokens.set(token, {
uuid: uuid.toLowerCase(),
name,
createdAt: Date.now(),
expiresAt: Date.now() + TOKEN_TTL_MS,
});
byUuid.set(uuid.toLowerCase(), token);
return token;
}
export function revokeToken(uuid: string): void {
const t = byUuid.get(uuid.toLowerCase());
if (t) tokens.delete(t);
byUuid.delete(uuid.toLowerCase());
}
function purgeExpired(): void {
const now = Date.now();
for (const [t, e] of tokens) {
if (e.expiresAt < now) {
tokens.delete(t);
byUuid.delete(e.uuid);
}
}
}
export interface AuthSession {
uuid: string;
name: string;
}
export function lookupBearer(headerValue: string | undefined): AuthSession | null {
if (!headerValue) return null;
const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
if (!match) return null;
purgeExpired();
const entry = tokens.get(match[1]);
if (!entry || entry.expiresAt < Date.now()) return null;
return { uuid: entry.uuid, name: entry.name };
}
export function extractAuthHeader(headers: Record<string, string>): string | undefined {
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === "authorization") return headers[k];
}
return undefined;
}
const JSON_HEADERS = { "Content-Type": "application/json; charset=utf-8" };
export function unauthorized(reason = "missing or invalid bearer token") {
return {
status: 401,
headers: JSON_HEADERS,
body: JSON.stringify({ error: reason }),
};
}
export function forbidden(perm: string) {
return {
status: 403,
headers: JSON_HEADERS,
body: JSON.stringify({ error: `missing permission: ${perm}` }),
};
}
/**
* Returns the authenticated session if the bearer-token's player has
* `perm`. Otherwise returns a Response that the caller must return.
*
* Usage:
* const auth = await requirePerm(c, "ward.user.edit");
* if ("status" in auth) return auth;
* // ... use auth.uuid / auth.name
*/
export async function requirePerm(
c: ServeContext,
perm: string,
): Promise<
| AuthSession
| { status: number; headers: Record<string, string>; body: string }
> {
const session = lookupBearer(extractAuthHeader(c.req.headers));
if (!session) return unauthorized();
const ok = await hasPermission(session.uuid, perm);
if (!ok) return forbidden(perm);
return session;
}
export function isAuthResponse(
v: unknown,
): v is { status: number; headers: Record<string, string>; body: string } {
return (
typeof v === "object" &&
v !== null &&
"status" in v &&
"headers" in v &&
"body" in v
);
}