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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user