import { createHmac, timingSafeEqual } from "node:crypto"; import type { Entitlements } from "@app/shared-types"; /** * Signerad entitlement-token för offline-läge (spec §44): * "Premium får inte vara permanent lokal boolean. Använd signerad * entitlement-token med begränsad giltighet och grace period." * * Kompakt HS256-JWT implementerad med node:crypto (inga extra beroenden). * Appen cachar token och litar på den offline tills exp + grace har passerat. */ export interface EntitlementTokenPayload { sub: string; // userId plan: Entitlements["plan"]; status: string; maxMembers: number; scansPerMonth: number; weekPlanning: boolean; advancedNutrition: boolean; communityPublish: boolean; iat: number; exp: number; /** Unix-sekunder: sista tidpunkt token accepteras offline (grace). */ graceExp: number; } const b64url = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const fromB64url = (s: string): Buffer => Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64"); export function signEntitlementToken( userId: string, entitlements: Entitlements, secret: string, options: { ttlHours?: number; graceDays?: number; now?: Date } = {}, ): string { const now = options.now ?? new Date(); const ttlHours = options.ttlHours ?? 24; const graceDays = options.graceDays ?? 7; const iat = Math.floor(now.getTime() / 1000); const exp = iat + ttlHours * 3600; const payload: EntitlementTokenPayload = { sub: userId, plan: entitlements.plan, status: entitlements.status, maxMembers: entitlements.maxHouseholdMembers, scansPerMonth: entitlements.aiScansPerMonth, weekPlanning: entitlements.weekPlanning, advancedNutrition: entitlements.advancedNutrition, communityPublish: entitlements.communityPublish, iat, exp, graceExp: exp + graceDays * 86_400, }; const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))); const body = b64url(Buffer.from(JSON.stringify(payload))); const signature = b64url(createHmac("sha256", secret).update(`${header}.${body}`).digest()); return `${header}.${body}.${signature}`; } export type TokenVerification = | { valid: true; payload: EntitlementTokenPayload; withinGrace: boolean } | { valid: false; reason: "malformed" | "bad_signature" | "expired_beyond_grace" }; export function verifyEntitlementToken( token: string, secret: string, now: Date = new Date(), ): TokenVerification { const parts = token.split("."); if (parts.length !== 3) return { valid: false, reason: "malformed" }; const [header, body, signature] = parts as [string, string, string]; const expected = createHmac("sha256", secret).update(`${header}.${body}`).digest(); const actual = fromB64url(signature); if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { return { valid: false, reason: "bad_signature" }; } let payload: EntitlementTokenPayload; try { payload = JSON.parse(fromB64url(body).toString("utf8")) as EntitlementTokenPayload; } catch { return { valid: false, reason: "malformed" }; } const nowSec = Math.floor(now.getTime() / 1000); if (nowSec > payload.graceExp) return { valid: false, reason: "expired_beyond_grace" }; return { valid: true, payload, withinGrace: nowSec > payload.exp }; }