diff --git a/packages/rpc/src/identity.ts b/packages/rpc/src/identity.ts new file mode 100644 index 00000000..0b24bd6a --- /dev/null +++ b/packages/rpc/src/identity.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 4ec5e5ae..02e98da8 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -22,3 +22,11 @@ export { RPC_ERROR_CODES, ServiceError, failure, isRetryableStatus, success } fr export type { RpcErrorCode, ToResultOptions } from "./errors.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"; diff --git a/packages/rpc/test/identity.test.ts b/packages/rpc/test/identity.test.ts new file mode 100644 index 00000000..8a55a08b --- /dev/null +++ b/packages/rpc/test/identity.test.ts @@ -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/); + }); +});