fix(rpc): close identity-token fail-open and validation gaps

- importSubjectContext now rejects a non-string/empty selfApp before
  verifying. verifyJwt skips the audience check entirely when audience
  is undefined, so an unvalidated selfApp (the natural shape of
  currentAppName(): string | undefined) accepted every token from every
  app for every audience.
- exportSubjectContext now rejects a non-string/empty targetApp, so an
  array can no longer mint one token valid at multiple apps.
- Both directions now reject a present-but-non-string tenant id instead
  of silently dropping it (was: callee reads missing tenantId as
  global/unscoped -> cross-tenant exposure).
- importSubjectContext now requires exp to be present and independently
  bounds accepted token age via a new maxAge/ImportOptions.maxAgeSeconds
  (default 300s), so a caller cannot mint a long-lived token via a huge
  ttlSeconds and have it honoured indefinitely.
- SubjectContext.callerApp doc now states it is self-asserted (the
  signing secret is workspace-wide) and must never be an authz input.
- index.ts also exports the new ImportOptions type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:26:14 +05:30
co-authored by Claude Opus 5
parent 1393a8a3b8
commit 83c99cc3e5
3 changed files with 100 additions and 10 deletions
+38
View File
@@ -91,6 +91,44 @@ describe("subject context token", () => {
).rejects.toThrow();
});
test("an empty selfApp is refused rather than disabling the audience check", async () => {
configure();
const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing");
// verifyJwt skips the audience check when audience is undefined, so this
// would otherwise accept every token from every app.
for (const bad of [undefined, "", null]) {
await expect(importSubjectContext(token!, bad as never)).rejects.toThrow(/selfApp/);
}
});
test("a token with a huge ttl is still rejected once it exceeds maxAge", async () => {
configure();
const token = await exportSubjectContext(ctxFor({ id: "u1" }), "billing", {
ttlSeconds: 31_536_000,
});
await expect(importSubjectContext(token!, "billing", { maxAgeSeconds: -1 })).rejects.toThrow();
});
test("an array targetApp is refused, so no token is valid at two apps", async () => {
configure();
await expect(
exportSubjectContext(ctxFor({ id: "u1" }), ["billing", "reports"] as never),
).rejects.toThrow(/targetApp/);
});
test("a non-string tenant id is refused rather than silently dropped", async () => {
configure();
// Silently dropping it leaves the callee reading "no tenant" as "global".
for (const tenant of [42, {}, ""]) {
await expect(
exportSubjectContext(
{ user: { id: "u1" }, tenant: { id: tenant }, locals: {} } as unknown as Context,
"billing",
),
).rejects.toThrow(/tenant/i);
}
});
test("a missing secret is a setup error, not a silent pass", async () => {
process.env.WRNEXUS_APP_NAME = "web";
delete process.env.WRNEXUS_RPC_SECRET;