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,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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