111 lines
2.9 KiB
TypeScript
111 lines
2.9 KiB
TypeScript
/** 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<CryptoKey> {
|
|
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<string> {
|
|
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<string> {
|
|
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<VerifiedMagicToken> {
|
|
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,
|
|
};
|
|
}
|