diff --git a/docs/plans/2026-08-05-inter-app-comms-implementation.md b/docs/plans/2026-08-05-inter-app-comms-implementation.md index fa5de0aa..f460a49d 100644 --- a/docs/plans/2026-08-05-inter-app-comms-implementation.md +++ b/docs/plans/2026-08-05-inter-app-comms-implementation.md @@ -932,12 +932,11 @@ 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 === "") - ) { + // ctx.tenant ABSENT means untenanted. ctx.tenant present with a null id + // means tenancy was expected and the id is missing, which must not silently + // widen scope to global while still issuing an authenticated credential. + 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).", ); @@ -976,6 +975,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, @@ -985,12 +986,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 accepts an array aud via includes(), so a multi-audience token + // verifies at several apps. Refusing it here makes the mint-side guard's + // invariant true where it is actually enforced. + 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."); } @@ -1011,7 +1026,7 @@ export { importSubjectContext, rpcSecret, } from "./identity.ts"; -export type { ExportOptions, SubjectContext } from "./identity.ts"; +export type { ExportOptions, ImportOptions, SubjectContext } from "./identity.ts"; ``` - [ ] **Step 4: Run test to verify it passes**