docs: close the iat fail-open and tighten the Task 4 identity guards

The round-1 fix required exp and passed maxAge, but verifyJwt gates its age
check on iat being a number - the identical shape to the two fail-opens that
round closed. A token minted without iat defeats the age bound at ANY
maxAgeSeconds, and a future-dated iat yields a negative age and does the
same. Both refused now, so maxAge means what ImportOptions says it means.

The mint side refused an array targetApp, but the import side never checked
that aud was a single string, and verifyJwt compares with includes(). So a
multi-audience token still verified at several apps - the invariant was true
only where it was not enforced. Now checked at the callee.

ctx.tenant present with a null id was treated as untenanted, silently
widening scope to global while still issuing an authenticated credential.
Absent ctx.tenant means global; a present tenant with an unusable id is an
error.

Also exports ImportOptions, which the append snippet omitted although the
Produces line names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:36:05 +05:30
co-authored by Claude Opus 5
parent 83c99cc3e5
commit 9f599e02e8
@@ -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**