feat(rpc): add the signed subject-context token
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { exportSubjectContext, importSubjectContext } from "../src/identity.ts";
|
||||
|
||||
const SECRET = "test-rpc-secret-at-least-32-chars-long";
|
||||
const original = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...original };
|
||||
});
|
||||
|
||||
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 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("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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user