57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
// 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<string, unknown> & { 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<T extends Record<string, unknown>>(
|
|
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<T extends TokenPayload = TokenPayload>(
|
|
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;
|
|
}
|
|
}
|