fix(rpc): close the iat fail-open and tighten the identity guards

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>
This commit is contained in:
2026-08-05 18:32:41 +05:30
co-authored by Claude Opus 5
parent 9f599e02e8
commit 9bc0f48514
2 changed files with 103 additions and 6 deletions
+22 -6
View File
@@ -97,12 +97,12 @@ export async function exportSubjectContext(
// 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.
const rawTenant: unknown = ctx.tenant?.id;
if (
rawTenant !== undefined &&
rawTenant !== null &&
(typeof rawTenant !== "string" || rawTenant === "")
) {
// 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).",
);
@@ -141,6 +141,8 @@ export async function importSubjectContext(
tenant?: unknown;
iss?: string;
exp?: number;
iat?: number;
aud?: unknown;
}>(token, rpcSecret(), {
audience: selfApp,
maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS,
@@ -150,12 +152,26 @@ export async function importSubjectContext(
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.");
}