lib/web-auth.ts
4.5 KB · sha256:1cfe5814ff3ebf2d62da2f1a9e9830bda143dacfd9bbce8cce90ce5a2823608a
import { hasPermission } from "../models/Player";
import { env } from "../config/env";
interface TokenEntry {
uuid: string;
name: string;
createdAt: number;
expiresAt: number;
}
const TOKEN_TTL_MS = Math.max(1, env.WEB_TOKEN_TTL_HOURS) * 60 * 60 * 1000;
const SWEEP_INTERVAL_MS =
Math.max(1, env.WEB_TOKEN_SWEEP_MINUTES) * 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 lcUuid = uuid.toLowerCase();
const existing = byUuid.get(lcUuid);
if (existing) tokens.delete(existing);
const token = newToken();
tokens.set(token, {
uuid: lcUuid,
name,
createdAt: Date.now(),
expiresAt: Date.now() + TOKEN_TTL_MS,
});
byUuid.set(lcUuid, token);
return token;
}
export function revokeToken(uuid: string): void {
const lcUuid = uuid.toLowerCase();
const t = byUuid.get(lcUuid);
if (t) tokens.delete(t);
byUuid.delete(lcUuid);
}
function purgeExpired(): number {
const now = Date.now();
let removed = 0;
for (const [t, e] of tokens) {
if (e.expiresAt < now) {
tokens.delete(t);
byUuid.delete(e.uuid);
removed++;
}
}
return removed;
}
// Periodic sweep so idle sessions don't keep their entries alive forever.
// Previously the only purge path was a lookup against an expired token --
// if no requests arrived, expired tokens stuck around. The sweeper handle
// is module-scoped so a second call to startTokenSweeper (e.g. after
// /rune reload) replaces the previous interval rather than stacking.
let sweepHandle: unknown = null;
export function startTokenSweeper(): void {
if (sweepHandle != null) {
try {
rune.schedule.cancel(sweepHandle);
} catch {}
sweepHandle = null;
}
sweepHandle = rune.schedule.everyMs(() => {
try {
const n = purgeExpired();
if (n > 0) console.info(`ward.web-auth: purged ${n} expired token(s)`);
} catch (e) {
console.warn(
"ward.web-auth: token sweep failed:",
(e as Error)?.message ?? e,
);
}
}, SWEEP_INTERVAL_MS);
}
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;
const entry = tokens.get(match[1]);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
// Lazy cleanup so an attacker hammering an expired token doesn't grow
// the map between sweeps. Cheap because we already have the entry.
tokens.delete(match[1]);
byUuid.delete(entry.uuid);
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 (isAuthResponse(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
);
}