From e47ea604a248eec72ab4cf90340e95cdb77f0cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:53:09 +0000 Subject: [PATCH] Fix AgentID link: call global fetch bare, not as a method (Workers Illegal-invocation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC client stored the bare global `fetch` on the instance (`this.fetchImpl = ... ?? fetch`) and called it as `this.fetchImpl(...)`. On Cloudflare Workers the native fetch throws "Illegal invocation" when its receiver is anything but the global realm, so every real token-exchange and JWKS request failed with: token endpoint unreachable: Illegal invocation: function called with incorrect `this` reference which surfaced to owners as "Verification failed. Could not verify the AgentID sign-in." — no link could ever complete in production. The mocked tests inject their own fetch, so the real global-fetch path was never exercised and CI stayed green. Wrap the fallback in an arrow so the global is always reached by a bare `fetch(...)` call, matching the existing convention in payments/arc.ts, circle-facilitator.ts and cdp-facilitator.ts. Add a regression test that spies on the global fetch and asserts the client never invokes it with the client instance as receiver. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NNqTSRyDjL1LcayDuUeDkr --- packages/api/src/agentid/oidc.test.ts | 26 ++++++++++++++++++++++++++ packages/api/src/agentid/oidc.ts | 10 +++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/api/src/agentid/oidc.test.ts b/packages/api/src/agentid/oidc.test.ts index b6d8fdc..851a056 100644 --- a/packages/api/src/agentid/oidc.test.ts +++ b/packages/api/src/agentid/oidc.test.ts @@ -221,6 +221,32 @@ describe('agentid/oidc verifyIdToken', () => { await expect(new AgentIdOidcClient(CFG, fetchImpl).exchangeCode('c', 'v')).rejects.toThrow(/token endpoint returned 400/); }); + it('invokes the global fetch as a bare call, never as a method (Workers Illegal-invocation guard)', async () => { + // With no injected impl and no test override, the client must fall back to the + // GLOBAL fetch — and reach it via a bare `fetch(...)` call, not `this.fetchImpl(...)`. + // Cloudflare's native fetch throws "Illegal invocation" when its receiver is + // anything but the global realm, which broke every real token/JWKS request in + // production. A mock fetch doesn't care about `this`, so we spy on the global + // directly and assert the receiver it was called with is not the client instance. + setAgentIdFetchForTests(undefined); // no override → exercise the global fallback + const realFetch = globalThis.fetch; + const receivers: unknown[] = []; + try { + globalThis.fetch = function (this: unknown) { + receivers.push(this); // record the receiver without aliasing `this` to a variable + return new Response(JSON.stringify({ id_token: 'a.b.c' }), { status: 200 }); + } as unknown as typeof fetch; + await new AgentIdOidcClient(CFG).exchangeCode('code', 'verifier'); // no fetchImpl → global fallback + } finally { + globalThis.fetch = realFetch; + } + expect(receivers).toHaveLength(1); + // The pre-fix code (`this.fetchImpl = fetch`) would call the spy with the client + // as receiver; the arrow-wrapped fallback calls it bare (receiver undefined). + expect(receivers[0]).not.toBeInstanceOf(AgentIdOidcClient); + expect(receivers[0]).toBeUndefined(); + }); + it('shares the JWKS cache across client instances (one fetch per URL)', async () => { setAgentIdFetchForTests(undefined); // clears the module-level JWKS cache const { kp, jwk } = await makeIssuerKey(); diff --git a/packages/api/src/agentid/oidc.ts b/packages/api/src/agentid/oidc.ts index 0412dc1..b8e3efb 100644 --- a/packages/api/src/agentid/oidc.ts +++ b/packages/api/src/agentid/oidc.ts @@ -150,9 +150,13 @@ export class AgentIdOidcClient { constructor(cfg: AgentIdConfig, fetchImpl?: typeof fetch) { this.cfg = cfg; - // Global fetch needs no `this` binding on Workers/undici; an injected impl - // (a test's vi.fn) is used verbatim. - this.fetchImpl = fetchImpl ?? testFetch ?? fetch; + // The global fetch throws "Illegal invocation" on Cloudflare Workers when it + // is called as a METHOD — and our call sites are `this.fetchImpl(...)`, whose + // receiver is this client instance, not the global realm. Wrap the fallback so + // the global is always reached by a bare `fetch(...)` call (the same pattern + // the payments facilitators use). An injected impl (a test's vi.fn) is used + // verbatim. See https://developers.cloudflare.com/workers/observability/errors/#illegal-invocation-errors + this.fetchImpl = fetchImpl ?? testFetch ?? ((input, init) => fetch(input, init)); } /** Exchange an authorization code for tokens (Basic client auth + PKCE). */