Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/api/src/agentid/oidc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 7 additions & 3 deletions packages/api/src/agentid/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading