Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user