feat(rpc): add the signed subject-context token

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:12:42 +05:30
co-authored by Claude Opus 5
parent 40625e98ed
commit 2257ee871e
3 changed files with 230 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import type { Context } from "@wrnexus/core";
import { signJwt, verifyJwt } from "@wrnexus/jwt";
/** Header the identity token travels in. */
export const RPC_IDENTITY_HEADER = "x-wrnexus-rpc-identity";
const MIN_SECRET_LENGTH = 32;
const DEFAULT_TTL_SECONDS = 60;
export interface SubjectContext {
subjectId: string;
tenantId?: string;
/** The app that minted the token. */
callerApp: string;
}
export interface ExportOptions {
ttlSeconds?: number;
}
/**
* The workspace-wide RPC signing secret.
*
* Deliberately separate from the session secret: reusing that would make a
* leaked RPC token a session-forgery primitive. All workspace apps share this
* secret, so they form ONE trust boundary — any app can mint a token naming
* any user, and compromising the lowest-privilege app compromises identity
* across all of them.
*/
export function rpcSecret(): string {
const secret = process.env.WRNEXUS_RPC_SECRET;
if (!secret) {
throw new Error(
"WRN-RPC-SECRET: WRNEXUS_RPC_SECRET is not set. Inter-app calls cannot carry " +
"identity without it. Use a value distinct from the session secret.",
);
}
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`WRN-RPC-SECRET: WRNEXUS_RPC_SECRET must be at least ${MIN_SECRET_LENGTH} characters.`,
);
}
return secret;
}
function callerAppName(): string {
const name = process.env.WRNEXUS_APP_NAME;
if (!name) {
throw new Error(
"WRN-RPC-APP: WRNEXUS_APP_NAME is not set, so a call cannot identify its caller.",
);
}
return name;
}
/**
* Mint a short-lived token naming the current subject, addressed to one app.
*
* Carries `sub` and `tenant` ONLY. Roles are deliberately absent: every app
* shares the PermissionStore, so the callee resolves them itself, which makes
* a stale or forged privilege claim impossible by construction.
*
* Returns undefined for an anonymous request — there is no identity to carry.
*/
export async function exportSubjectContext(
ctx: Context,
targetApp: string,
options: ExportOptions = {},
): Promise<string | undefined> {
const rawId: unknown = (ctx.user as { id?: unknown } | null | undefined)?.id;
if (rawId === undefined || rawId === null) return undefined;
if (typeof rawId !== "string" || rawId === "") {
throw new Error(
"WRN-RPC-SUBJECT: subject id must be a non-empty string; coerce numeric ids with String(id).",
);
}
const tenantId = ctx.tenant?.id;
return signJwt(
{ sub: rawId, ...(typeof tenantId === "string" && tenantId ? { tenant: tenantId } : {}) },
rpcSecret(),
{
issuer: callerAppName(),
audience: targetApp,
expiresIn: options.ttlSeconds ?? DEFAULT_TTL_SECONDS,
},
);
}
/**
* Verify a token addressed to THIS app and return the subject it names.
*
* `selfApp` is the audience check: it is what stops app B replaying a token it
* received from A against a third app C.
*/
export async function importSubjectContext(
token: string,
selfApp: string,
): Promise<SubjectContext> {
const claims = await verifyJwt<{ sub?: string; tenant?: string; iss?: string }>(
token,
rpcSecret(),
{ audience: selfApp },
);
if (typeof claims.sub !== "string" || claims.sub === "") {
throw new Error("WRN-RPC-IDENTITY: token carries no usable subject.");
}
if (typeof claims.iss !== "string" || claims.iss === "") {
throw new Error("WRN-RPC-IDENTITY: token names no calling app.");
}
return {
subjectId: claims.sub,
tenantId: typeof claims.tenant === "string" && claims.tenant ? claims.tenant : undefined,
callerApp: claims.iss,
};
}
+8
View File
@@ -22,3 +22,11 @@ export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } fr
export type { RpcErrorCode, ToResultOptions } from "./errors.ts"; export type { RpcErrorCode, ToResultOptions } from "./errors.ts";
export { defineService, procedure, ProcedureBuilder } from "./contract.ts"; export { defineService, procedure, ProcedureBuilder } from "./contract.ts";
export {
RPC_IDENTITY_HEADER,
exportSubjectContext,
importSubjectContext,
rpcSecret,
} from "./identity.ts";
export type { ExportOptions, SubjectContext } from "./identity.ts";
+107
View File
@@ -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/);
});
});