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; /** Upper bound on accepted token age, whatever the token's own exp says. */ const DEFAULT_MAX_AGE_SECONDS = 300; export interface SubjectContext { subjectId: string; tenantId?: string; /** * The app that CLAIMS to have minted the token. Self-asserted: the signing * secret is workspace-wide, so any app can set this to any name. Useful for * logs and tracing; NEVER an authorization input. */ callerApp: string; } export interface ExportOptions { ttlSeconds?: number; } export interface ImportOptions { /** * Reject a token older than this regardless of its own `exp`, so a caller * that mints with a huge ttlSeconds cannot create a long-lived * impersonation credential the callee will honour. Defaults to 300s. */ maxAgeSeconds?: 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).", ); } // An array here would mint one token valid at SEVERAL apps, defeating the // audience binding that stops app B replaying A's token against app C. if (typeof targetApp !== "string" || targetApp === "") { throw new Error("WRN-RPC-AUDIENCE: targetApp must be a non-empty string."); } // A numeric tenant id is the common DB-backed case. Dropping it silently // would leave the callee reading "no tenant" as "global", which is a // cross-tenant exposure — so refuse it the same way a bad subject is refused. // Absent ctx.tenant means untenanted (fine); a PRESENT tenant with an // unusable id (including null) is an error, not a silent downgrade — unlike // rawId === null, which mints no token at all, a bad tenant must not issue // an authenticated credential with silently widened scope. const rawTenant: unknown = ctx.tenant === undefined ? undefined : ctx.tenant.id; if (rawTenant !== undefined && (typeof rawTenant !== "string" || rawTenant === "")) { throw new Error( "WRN-RPC-TENANT: tenant id must be a non-empty string; coerce numeric ids with String(id).", ); } return signJwt( { sub: rawId, ...(rawTenant ? { tenant: rawTenant as string } : {}) }, 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, options: ImportOptions = {}, ): Promise { // verifyJwt SKIPS the audience check entirely when audience is undefined, so // an empty selfApp would disable the only cross-app binding in the system and // accept every token from every app. currentAppName() returns // `string | undefined`, which is exactly how that gets passed by accident. if (typeof selfApp !== "string" || selfApp === "") { throw new Error("WRN-RPC-AUDIENCE: selfApp must be a non-empty string."); } const claims = await verifyJwt<{ sub?: string; tenant?: unknown; iss?: string; exp?: number; iat?: number; aud?: unknown; }>(token, rpcSecret(), { audience: selfApp, maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS, }); // verifyJwt only checks exp when it is present, so a token minted without // one never expires. Require it. if (typeof claims.exp !== "number") { throw new Error("WRN-RPC-IDENTITY: token has no expiry."); } // Same shape one level down: verifyJwt's maxAge check is gated on iat being // a number, so a token minted without iat silently defeats the age bound at // ANY maxAgeSeconds. A future-dated iat yields a negative age and does the // same. Both must be refused for maxAge to mean anything. const now = Math.floor(Date.now() / 1000); if (typeof claims.iat !== "number" || claims.iat > now + 60) { throw new Error("WRN-RPC-IDENTITY: token has no usable issued-at."); } 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."); } // verifyJwt compares audience with includes(), so a token signed with // audience: ["billing", "reports"] verifies at BOTH — exactly what I3 // exists to prevent. Require an exact single-audience match. if (claims.aud !== selfApp) { throw new Error("WRN-RPC-IDENTITY: token is addressed to more than this app."); } if (claims.tenant !== undefined && (typeof claims.tenant !== "string" || claims.tenant === "")) { throw new Error("WRN-RPC-IDENTITY: token carries an unusable tenant."); } return { subjectId: claims.sub, tenantId: claims.tenant as string | undefined, callerApp: claims.iss, }; }