95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
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 };
|
|
}
|