diff --git a/shared/session-token.ts b/shared/session-token.ts new file mode 100644 index 0000000..8db0798 --- /dev/null +++ b/shared/session-token.ts @@ -0,0 +1,56 @@ +// 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; + } +} diff --git a/shared/slugify.ts b/shared/slugify.ts new file mode 100644 index 0000000..d30a233 --- /dev/null +++ b/shared/slugify.ts @@ -0,0 +1,29 @@ +// Name → handle / slug helpers shared by frontend + proxy. + +// URL-safe family slug (hyphenated, lowercase): "The Smiths" → "the-smiths". +export function slugify(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Whitespace-free lowercase handle used for a member's URL segment and as the +// per-family username suffix: "Jakey Boy" → "jakeyboy", "Joe Edhook" → "joeedhook". +export function handle(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +// A member's PB username is namespaced by the family slug so it stays globally +// unique (PB requires auth-identity usernames to be unique) even though the URL +// segment is only unique within a family: "miss-fits:jakeyboy". +export function famUsername(famSlug: string, handleName: string): string { + return `${slugify(famSlug)}:${handleName}`; +} + +// Reverse of famUsername — the URL segment is the part after the last ":". +export function handleOf(fullUsername: string): string { + const i = fullUsername.lastIndexOf(':'); + return i >= 0 ? fullUsername.slice(i + 1) : fullUsername; +} \ No newline at end of file