// Minimal signed-token helper (HMAC-SHA256, JWT-ish but not JWT) shared by the // Hono proxy (issues + verifies) and the SvelteKit frontend (verifies locally // so hooks.server.ts doesn't need a round-trip to the proxy on every request). // // The proxy independently re-verifies the same token on every write, so the // frontend's local verification is a performance optimization, not the // security boundary — the boundary is the proxy. import crypto from "node:crypto"; // Dev-only fallback secrets. Both sides must derive the same default when the // real env var isn't set, so local dev works without extra setup. Production // MUST set SESSION_TOKEN_SECRET / PLATFORM_TOKEN_SECRET to a strong value. export const DEV_SESSION_TOKEN_SECRET = "famchamp-dev-session-secret-change-me"; export const DEV_PLATFORM_TOKEN_SECRET = "famchamp-dev-platform-secret-change-me"; export type TokenPayload = Record & { exp: number }; function encode(json: string): string { return Buffer.from(json).toString("base64url"); } function sign(secret: string, encodedPayload: string): string { return crypto.createHmac("sha256", secret).update(encodedPayload).digest("base64url"); } /** Signs `payload` (plus an `exp` computed from `ttlMs`) into an opaque token string. */ export function issueToken>( secret: string, payload: T, ttlMs: number, ): string { const encoded = encode(JSON.stringify({ ...payload, exp: Date.now() + ttlMs })); return `${encoded}.${sign(secret, encoded)}`; } /** Verifies signature + expiry. Returns the decoded payload, or null if invalid/expired/tampered. */ export function verifyToken( secret: string, token: string | undefined | null, ): T | null { if (!token) return null; const [encoded, sig] = token.split("."); if (!encoded || !sig) return null; const expected = sign(secret, encoded); const a = Buffer.from(sig); const b = Buffer.from(expected); // Constant-time comparison — avoids leaking signature bytes via timing. if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null; try { const payload = JSON.parse(Buffer.from(encoded, "base64url").toString()) as T; if (typeof payload.exp !== "number" || payload.exp <= Date.now()) return null; return payload; } catch { return null; } }