Files
Cibello-app/packages/subscriptions/src/token.ts
T
2026-08-05 19:21:11 +07:00

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 };
}