verifyJwt gates its maxAge check on iat being a number, so a token forged without iat was honoured at any maxAgeSeconds - the same shape as the audience and exp fail-opens closed in the previous round. A future-dated iat did the same via a negative age. Both refused now. The import side never checked aud was a single string, and verifyJwt compares with includes(), so a multi-audience token verified at several apps. The mint-side guard's invariant now holds where it is enforced. ctx.tenant present with a null id minted an authenticated credential with no tenant claim, which the callee reads as global. Absent ctx.tenant means untenanted; a present tenant with an unusable id is an error. Adds six tests pinning behaviours that mutation testing showed were free to delete without any test noticing: no-exp, no-iat, the 300s default max age, an array audience on import, a non-string tenant claim on import, and a null tenant id at mint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
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<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).",
|
|
);
|
|
}
|
|
// 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<SubjectContext> {
|
|
// 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,
|
|
};
|
|
}
|