Adds examples/auth-showcase/app/services/greeter-client.ts so the showcase demonstrates both halves - the review noted the example was callee-only, so a developer had no working reference for making a call. Raises integration coverage to the planned 3 tests and adds the missing rpc-endpoint cases. Also wires the prod build path for services. 304 tests pass across rpc/router/dev-server/cli; typecheck, lint, format and check:public-api all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
9.1 KiB
TypeScript
231 lines
9.1 KiB
TypeScript
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
|
|
// wired 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/);
|
|
});
|
|
});
|