/** HMAC-SHA256 signed tokens for magic link responses. */ import { magicLinkConfig } from "./config"; export interface MagicLinkPayload { /** space slug */ s: string; /** target type */ t: "poll" | "rsvp"; /** target id (sessionId or eventId) */ i: string; /** respondent name/label */ n: string; /** expiry (unix seconds) */ e: number; } interface TokenData extends MagicLinkPayload { /** hex signature */ h: string; } async function getKey(): Promise { return crypto.subtle.importKey( "raw", new TextEncoder().encode(magicLinkConfig.hmacSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ); } function toBase64Url(data: string): string { return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function fromBase64Url(b64: string): string { return atob(b64.replace(/-/g, "+").replace(/_/g, "/")); } function toHex(buf: ArrayBuffer): string { return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } async function sign(payload: string): Promise { const key = await getKey(); const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); return toHex(sig); } export async function createMagicToken( space: string, type: "poll" | "rsvp", targetId: string, respondentName: string, ): Promise { const expiry = Math.floor(Date.now() / 1000) + magicLinkConfig.tokenExpiryDays * 86400; const payload: MagicLinkPayload = { s: space, t: type, i: targetId, n: respondentName, e: expiry }; const payloadStr = `${payload.s}:${payload.t}:${payload.i}:${payload.n}:${payload.e}`; const sig = await sign(payloadStr); const token: TokenData = { ...payload, h: sig }; return toBase64Url(JSON.stringify(token)); } export interface VerifiedMagicToken { space: string; type: "poll" | "rsvp"; targetId: string; respondentName: string; /** Short hash for dedup keying */ tokenHash: string; } export async function verifyMagicToken(token: string): Promise { let data: TokenData; try { data = JSON.parse(fromBase64Url(token)); } catch { throw new Error("Invalid token format"); } if (!data.s || !data.t || !data.i || !data.n || !data.e || !data.h) { throw new Error("Malformed token"); } if (data.t !== "poll" && data.t !== "rsvp") { throw new Error("Invalid target type"); } if (data.e < Math.floor(Date.now() / 1000)) { throw new Error("Token expired"); } const payloadStr = `${data.s}:${data.t}:${data.i}:${data.n}:${data.e}`; const expected = await sign(payloadStr); if (data.h !== expected) { throw new Error("Invalid signature"); } // Short hash for dedup (first 12 chars of signature) const tokenHash = data.h.slice(0, 12); return { space: data.s, type: data.t, targetId: data.i, respondentName: data.n, tokenHash, }; }