import { afterEach, describe, expect, test } from "bun:test"; import type { Context } from "@wrnexus/core"; import { signJwt } from "@wrnexus/jwt"; import { exportSubjectContext, importSubjectContext } from "../src/identity.ts"; const SECRET = "test-rpc-secret-at-least-32-chars-long"; const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET; const originalAppName = process.env.WRNEXUS_APP_NAME; afterEach(() => { if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret; if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME; else process.env.WRNEXUS_APP_NAME = originalAppName; }); function ctxFor(user: unknown, tenantId?: string): Context { return { user, tenant: tenantId ? { id: tenantId } : undefined, locals: {}, } as unknown as Context; } function configure(appName = "web") { process.env.WRNEXUS_RPC_SECRET = SECRET; process.env.WRNEXUS_APP_NAME = appName; } describe("subject context token", () => { test("round-trips subject and tenant", async () => { configure("web"); const token = await exportSubjectContext(ctxFor({ id: "u1" }, "acme"), "billing"); const imported = await importSubjectContext(token!, "billing"); expect(imported.subjectId).toBe("u1"); expect(imported.tenantId).toBe("acme"); expect(imported.callerApp).toBe("web"); }); test("carries NO roles or permissions", async () => { configure(); const token = await exportSubjectContext( ctxFor({ id: "u1", roles: ["admin"], permissions: ["*"] }), "billing", ); // Decode the payload directly: the claim set must not include privileges. const payload = JSON.parse(atob(token!.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/"))); expect(payload.roles).toBeUndefined(); expect(payload.permissions).toBeUndefined(); expect(payload.sub).toBe("u1"); }); test("an anonymous context produces no token", async () => { configure(); expect(await exportSubjectContext(ctxFor(null), "billing")).toBeUndefined(); expect(await exportSubjectContext(ctxFor({}), "billing")).toBeUndefined(); }); test("a non-string subject id is refused", async () => { configure(); // Matches the permissions system: only a non-empty string identifies a subject. for (const id of [0, "", 123, {}]) { await expect(exportSubjectContext(ctxFor({ id }), "billing")).rejects.toThrow(/subject/i); } }); test("a token minted for one app is rejected by another", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); await expect(importSubjectContext(token!, "reports")).rejects.toThrow(); }); test("an expired token is rejected", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { ttlSeconds: -1 }); await expect(importSubjectContext(token!, "billing")).rejects.toThrow(); }); test("a forged token with no exp is refused", async () => { configure(); // verifyJwt only checks exp when present, so a token minted without one // never expires unless importSubjectContext requires it explicitly. const token = await signJwt({ sub: "u1" }, SECRET, { issuer: "web", audience: "billing" }); await expect(importSubjectContext(token, "billing")).rejects.toThrow(/expiry/i); }); test("a forged token with no iat is refused", async () => { configure(); // signJwt always sets iat unless the payload explicitly overrides it with // undefined (JSON.stringify then drops the key). verifyJwt's maxAge check // is gated on iat being a number, so this would otherwise defeat the age // bound at ANY maxAgeSeconds. const token = await signJwt({ sub: "u1", iat: undefined }, SECRET, { issuer: "web", audience: "billing", expiresIn: 60, }); await expect(importSubjectContext(token, "billing")).rejects.toThrow(/issued-at/i); }); test("a token signed with a different secret is rejected", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); process.env.WRNEXUS_RPC_SECRET = "a-completely-different-secret-32-chars"; await expect(importSubjectContext(token!, "billing")).rejects.toThrow(); }); test("a tampered payload is rejected", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); const [header, , signature] = token!.split("."); const forged = btoa(JSON.stringify({ sub: "admin", aud: "billing", iss: "web" })) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); await expect( importSubjectContext(`${header}.${forged}.${signature}`, "billing"), ).rejects.toThrow(); }); test("an empty selfApp is refused rather than disabling the audience check", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing"); // verifyJwt skips the audience check when audience is undefined, so this // would otherwise accept every token from every app. for (const bad of [undefined, "", null]) { await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/); } }); test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => { configure(); const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", { ttlSeconds: 31_536_000, }); await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow(); }); test("a backdated iat is refused under the default max age, and accepted once maxAgeSeconds is raised", async () => { configure(); // Age 400s, older than DEFAULT_MAX_AGE_SECONDS (300s). Calling with NO // options (the real default) must reject -- unlike the huge-ttl test // above, which always passes an explicit maxAgeSeconds: -1 and so never // actually exercises the 300s constant itself. const now = Math.floor(Date.now() / 1000); const token = await signJwt({ sub: "u1" }, SECRET, { issuer: "web", audience: "billing", expiresIn: 1000, now: now - 400, }); await expect(importSubjectContext(token, "billing")).rejects.toThrow(); // Same token, wider explicit bound: proves maxAgeSeconds is actually // connected through rather than the guard being a hardcoded rejection. await expect( importSubjectContext(token, "billing", { maxAgeSeconds: 1000 }), ).resolves.toBeDefined(); }); test("an array targetApp is refused, so no token is valid at two apps", async () => { configure(); await expect( exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never), ).rejects.toThrow(/targetApp/); }); test("a token signed with an array audience is refused on import", async () => { configure(); // verifyJwt compares audience with includes(), so a token signed with // audience: ["billing", "reports"] would verify at BOTH apps -- exactly // what the audience binding exists to prevent -- unless importSubjectContext // requires an exact single-audience match itself. const token = await signJwt({ sub: "u1" }, SECRET, { issuer: "web", audience: ["billing", "reports"], expiresIn: 60, }); await expect(importSubjectContext(token, "billing")).rejects.toThrow(); }); test("a non-string tenant id is refused rather than silently dropped", async () => { configure(); // Silently dropping it leaves the callee reading "no tenant" as "global". for (const tenant of [42, {}, ""]) { await expect( exportSubjectContext( { user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context, "billing", ), ).rejects.toThrow(/tenant/i); } }); test("ctx.tenant = { id: null } is refused at mint rather than minting untenanted", async () => { configure(); // Unlike rawId === null (mints no token at all), a present tenant with an // unusable id must not silently issue an authenticated credential with // widened (untenanted/global) scope. await expect( exportSubjectContext( { user: { id: "u1" }, tenant: { id: null }, locals: {} } as unknown as Context, "billing", ), ).rejects.toThrow(/tenant/i); }); test("a forged token whose tenant claim is a number is refused on import", async () => { configure(); const token = await signJwt({ sub: "u1", tenant: 42 }, SECRET, { issuer: "web", audience: "billing", expiresIn: 60, }); await expect(importSubjectContext(token, "billing")).rejects.toThrow(/tenant/i); }); test("a missing secret is a setup error, not a silent pass", async () => { process.env.WRNEXUS_APP_NAME = "web"; delete process.env.WRNEXUS_RPC_SECRET; await expect(exportSubjectContext(ctxFor({ id: "u1" }), "billing")).rejects.toThrow( /WRNEXUS_RPC_SECRET/, ); }); test("a short secret is refused", async () => { process.env.WRNEXUS_APP_NAME = "web"; process.env.WRNEXUS_RPC_SECRET = "too-short"; await expect(exportSubjectContext(ctxFor({ id: "u1" }), "billing")).rejects.toThrow(/32/); }); });