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 { defineService, procedure, ProcedureBuilder } from "./contract.ts";
export {
RPC_IDENTITY_HEADER,
exportSubjectContext,
importSubjectContext,
rpcSecret,
} from "./identity.ts";
export type { ExportOptions, SubjectContext } from "./identity.ts";