From 184e5ea3f91bfd5f01f2e3705bde5aba84ceb489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20Akdo=C4=9Fan?= <70746444+furkan3152@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:18:29 +0000 Subject: [PATCH 1/3] fix(did): apply the resolver timeout to did:jwks fetches getDidResolver() wraps fetch for the did:jwks resolver to inject the redirect policy, but passed no AbortSignal, so the webOptions.timeout (default 5000ms) added for did:web in #129 never bounded did:jwks. A did:jwks host that stalls its response held resolution, and any verification waiting on it, open with no bound from this library. Abort each did:jwks fetch (JWKS, OpenID configuration, jwks_uri) after the same timeout. --- .changeset/did-jwks-fetch-timeout.md | 15 ++++++ .../did-resolvers/get-did-resolver.test.ts | 51 +++++++++++++++++++ .../did/src/did-resolvers/get-did-resolver.ts | 14 ++++- .../did/src/did-resolvers/web-did-resolver.ts | 7 ++- 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 .changeset/did-jwks-fetch-timeout.md diff --git a/.changeset/did-jwks-fetch-timeout.md b/.changeset/did-jwks-fetch-timeout.md new file mode 100644 index 00000000..e073699a --- /dev/null +++ b/.changeset/did-jwks-fetch-timeout.md @@ -0,0 +1,15 @@ +--- +"@agentcommercekit/did": patch +--- + +`getDidResolver()` now applies the `webOptions.timeout` (default 5000ms) to the +fetches it makes when resolving a `did:jwks` DID. #129 added the timeout to the +`did:web` resolver, but the `did:jwks` resolver gets its own `fetch` wrapper, +which passed no `AbortSignal`. Resolving a `did:jwks` DID fetches the host +named in the DID, so a host that accepted the connection and never answered +held the resolution, and every JWT, credential or receipt verification waiting +on it, open with no bound from this library. Each of the up to three requests a +`did:jwks` resolution makes (the JWKS, the OpenID configuration and its +`jwks_uri`) is now aborted after the timeout, and the resolution reports an +error. A custom `fetch` must honour `init.signal` for the timeout to take +effect. diff --git a/packages/did/src/did-resolvers/get-did-resolver.test.ts b/packages/did/src/did-resolvers/get-did-resolver.test.ts index c5395e78..3f160343 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.test.ts @@ -56,4 +56,55 @@ describe("getDidResolver", () => { } }) }) + + describe("did:jwks timeout", () => { + it("aborts a did:jwks fetch that exceeds the configured timeout", async () => { + // A host that never answers: the request settles only when its signal + // aborts. + const mockFetch = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(init.signal?.reason) + }) + }), + ) + + const resolver = getDidResolver({ + webOptions: { fetch: mockFetch, timeout: 50 }, + }) + const result = await resolver.resolve("did:jwks:example.com") + + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/jwks.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(result.didDocument).toBeNull() + expect(result.didResolutionMetadata.error).toBeDefined() + }) + + it("applies the 5000ms default timeout to did:jwks fetches", async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 404 })) + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + + try { + const resolver = getDidResolver({ webOptions: { fetch: mockFetch } }) + await resolver.resolve("did:jwks:example.com") + + expect(timeoutSpy).toHaveBeenCalledWith(5000) + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/jwks.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/openid-configuration", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + } finally { + timeoutSpy.mockRestore() + } + }) + }) }) diff --git a/packages/did/src/did-resolvers/get-did-resolver.ts b/packages/did/src/did-resolvers/get-did-resolver.ts index f793c8a7..1612255e 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -5,13 +5,15 @@ import { getResolver as getKeyDidResolver } from "key-did-resolver" import { DidResolver } from "./did-resolver" import { getResolver as getPkhDidResolver } from "./pkh-did-resolver" import { + DEFAULT_TIMEOUT_MS, getResolver as getWebDidResolver, type DidWebResolverOptions, } from "./web-did-resolver" interface GetDidResolverOptions extends ResolverOptions { /** - * The options for the did:web resolver + * The options for the did:web resolver. The did:jwks resolver shares + * `fetch`, `allowedHttpHosts`, `followRedirects` and `timeout`. */ webOptions?: DidWebResolverOptions } @@ -29,15 +31,23 @@ export function getDidResolver({ ...options }: GetDidResolverOptions = {}): DidResolver { const webFetch = webOptions.fetch ?? globalThis.fetch + const timeout = webOptions.timeout ?? DEFAULT_TIMEOUT_MS const keyResolver = getKeyDidResolver() + // Validates `timeout`, so the did:jwks fetches below can rely on it too. const webResolver = getWebDidResolver(webOptions) - // did-jwks calls fetch with no init, so inject the redirect policy here + // did-jwks calls fetch with no init, so inject the redirect policy and the + // timeout here. Each did:jwks resolution can fetch up to three URLs (the + // JWKS, the OpenID configuration and its `jwks_uri`), and every one of them + // targets a host named in the DID, so each is bounded like a did:web fetch. const jwksResolver = getJwksDidResolver({ ...webOptions, fetch: (input, init) => webFetch(input, { ...init, redirect: webOptions.followRedirects ? "follow" : "manual", + signal: init?.signal + ? AbortSignal.any([init.signal, AbortSignal.timeout(timeout)]) + : AbortSignal.timeout(timeout), }), }) const pkhResolver = getPkhDidResolver() diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index 71585930..fb95ce2a 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -72,6 +72,11 @@ const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = [] const DEFAULT_DOC_PATH = "/.well-known/did.json" const MAX_TIMEOUT_MS = 2147483647 +/** + * Default milliseconds to wait for a DID document fetch. + */ +export const DEFAULT_TIMEOUT_MS = 5000 + /** * Get a did document from a url and validate that it is a DidDocument * @@ -189,7 +194,7 @@ export function getResolver({ fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, followRedirects = false, - timeout = 5000, + timeout = DEFAULT_TIMEOUT_MS, }: DidWebResolverOptions = {}): { web: DIDResolver } { // Fail fast on a bad timeout rather than surfacing it later as a // misleading `notFound` resolution error. `AbortSignal.timeout` throws on From 7ec4acaec13706dd3eeb2d2939d3628724c400f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20Akdo=C4=9Fan?= <70746444+furkan3152@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:09:46 +0000 Subject: [PATCH 2/3] fix(did): report a did:jwks timeout as notFound so it isn't cached did-resolver's in-memory cache stores every result except notFound, and the did:jwks resolver reports any fetch failure as internalError. With cache: true, a transient timeout was cached and the DID never resolved again. Report a timeout as notFound, as did:web does. --- .changeset/did-jwks-fetch-timeout.md | 2 +- .../did-resolvers/get-did-resolver.test.ts | 41 ++++++++++++- .../did/src/did-resolvers/get-did-resolver.ts | 59 +++++++++++++++---- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/.changeset/did-jwks-fetch-timeout.md b/.changeset/did-jwks-fetch-timeout.md index e073699a..60021151 100644 --- a/.changeset/did-jwks-fetch-timeout.md +++ b/.changeset/did-jwks-fetch-timeout.md @@ -12,4 +12,4 @@ on it, open with no bound from this library. Each of the up to three requests a `did:jwks` resolution makes (the JWKS, the OpenID configuration and its `jwks_uri`) is now aborted after the timeout, and the resolution reports an error. A custom `fetch` must honour `init.signal` for the timeout to take -effect. +effect. A timed-out `did:jwks` resolution is reported as `notFound`, like a `did:web` timeout, so it is not kept in the resolver cache when `cache: true` is set. diff --git a/packages/did/src/did-resolvers/get-did-resolver.test.ts b/packages/did/src/did-resolvers/get-did-resolver.test.ts index 3f160343..bb2f7350 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.test.ts @@ -80,7 +80,46 @@ describe("getDidResolver", () => { expect.objectContaining({ signal: expect.any(AbortSignal) }), ) expect(result.didDocument).toBeNull() - expect(result.didResolutionMetadata.error).toBeDefined() + expect(result.didResolutionMetadata.error).toBe("notFound") + }) + + it("does not cache a timed-out did:jwks resolution when caching is enabled", async () => { + const jwks = { + keys: [ + { + kty: "OKP", + crv: "Ed25519", + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + }, + ], + } + let hostIsUp = false + const mockFetch = vi.fn((input, init) => { + const url = input instanceof Request ? input.url : input.toString() + if (hostIsUp && url.endsWith("/.well-known/jwks.json")) { + return Promise.resolve(Response.json(jwks)) + } + if (hostIsUp) { + return Promise.resolve(new Response(null, { status: 404 })) + } + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(init.signal?.reason) + }) + }) + }) + + const resolver = getDidResolver({ + cache: true, + webOptions: { fetch: mockFetch, timeout: 50 }, + }) + const first = await resolver.resolve("did:jwks:example.com") + expect(first.didDocument).toBeNull() + + hostIsUp = true + const second = await resolver.resolve("did:jwks:example.com") + expect(second.didResolutionMetadata.error).toBeUndefined() + expect(second.didDocument).not.toBeNull() }) it("applies the 5000ms default timeout to did:jwks fetches", async () => { diff --git a/packages/did/src/did-resolvers/get-did-resolver.ts b/packages/did/src/did-resolvers/get-did-resolver.ts index 1612255e..2d3d2f93 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -1,4 +1,4 @@ -import type { ResolverOptions } from "did-resolver" +import type { DIDResolver, ResolverOptions } from "did-resolver" import { getResolver as getJwksDidResolver } from "jwks-did-resolver" import { getResolver as getKeyDidResolver } from "key-did-resolver" @@ -39,24 +39,57 @@ export function getDidResolver({ // timeout here. Each did:jwks resolution can fetch up to three URLs (the // JWKS, the OpenID configuration and its `jwks_uri`), and every one of them // targets a host named in the DID, so each is bounded like a did:web fetch. - const jwksResolver = getJwksDidResolver({ - ...webOptions, - fetch: (input, init) => - webFetch(input, { - ...init, - redirect: webOptions.followRedirects ? "follow" : "manual", - signal: init?.signal - ? AbortSignal.any([init.signal, AbortSignal.timeout(timeout)]) - : AbortSignal.timeout(timeout), - }), - }) + // The resolver is built per resolution so a timeout can be told apart from + // other fetch failures. + const resolveJwks: DIDResolver = async ( + did, + parsed, + resolver, + resolveOptions, + ) => { + let timedOut = false + const { jwks } = getJwksDidResolver({ + ...webOptions, + fetch: (input, init) => { + const timeoutSignal = AbortSignal.timeout(timeout) + timeoutSignal.addEventListener( + "abort", + () => { + timedOut = true + }, + { once: true }, + ) + return webFetch(input, { + ...init, + redirect: webOptions.followRedirects ? "follow" : "manual", + signal: init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal, + }) + }, + }) + const result = await jwks(did, parsed, resolver, resolveOptions) + // Report a timeout as `notFound`, like did:web does. did-resolver's cache + // stores every other error, so an `internalError` here would keep a + // transient timeout cached when `cache: true` is set. + if (timedOut && result.didResolutionMetadata.error === "internalError") { + return { + ...result, + didResolutionMetadata: { + ...result.didResolutionMetadata, + error: "notFound", + }, + } + } + return result + } const pkhResolver = getPkhDidResolver() const didResolver = new DidResolver( { ...keyResolver, ...webResolver, - ...jwksResolver, + jwks: resolveJwks, ...pkhResolver, }, options, From 4105e185743733425c04e995a107736f77a8b5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20Akdo=C4=9Fan?= <70746444+furkan3152@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:21:48 +0000 Subject: [PATCH 3/3] fix(did): only count a did:jwks timeout when it aborted a fetch The timeout listener fired when a fetch's timer expired even if that fetch had already settled, so a later non-timeout failure in the same resolution could be reported as notFound. Check the timeout signal when the fetch fails instead. --- .../did-resolvers/get-did-resolver.test.ts | 28 +++++++++++++++++++ .../did/src/did-resolvers/get-did-resolver.ts | 14 +++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/did/src/did-resolvers/get-did-resolver.test.ts b/packages/did/src/did-resolvers/get-did-resolver.test.ts index bb2f7350..b7960f72 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.test.ts @@ -83,6 +83,34 @@ describe("getDidResolver", () => { expect(result.didResolutionMetadata.error).toBe("notFound") }) + it("reports a later non-timeout failure as internalError after an earlier fetch's timer fires", async () => { + // The JWKS request answers 404 at 100ms, so did-jwks moves on to the + // OpenID configuration, which fails at ~250ms: after the first + // request's timer fired (200ms) but before its own (~300ms). + const mockFetch = vi.fn( + (input) => + new Promise((resolve, reject) => { + const url = input instanceof Request ? input.url : input.toString() + if (url.endsWith("/.well-known/jwks.json")) { + setTimeout(() => { + resolve(new Response(null, { status: 404 })) + }, 100) + } else { + setTimeout(() => { + reject(new TypeError("fetch failed")) + }, 150) + } + }), + ) + + const resolver = getDidResolver({ + webOptions: { fetch: mockFetch, timeout: 200 }, + }) + const result = await resolver.resolve("did:jwks:example.com") + + expect(result.didResolutionMetadata.error).toBe("internalError") + }) + it("does not cache a timed-out did:jwks resolution when caching is enabled", async () => { const jwks = { keys: [ diff --git a/packages/did/src/did-resolvers/get-did-resolver.ts b/packages/did/src/did-resolvers/get-did-resolver.ts index 2d3d2f93..d6797a68 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -52,19 +52,19 @@ export function getDidResolver({ ...webOptions, fetch: (input, init) => { const timeoutSignal = AbortSignal.timeout(timeout) - timeoutSignal.addEventListener( - "abort", - () => { - timedOut = true - }, - { once: true }, - ) return webFetch(input, { ...init, redirect: webOptions.followRedirects ? "follow" : "manual", signal: init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal, + }).catch((error: unknown) => { + // Only a fetch that failed because its own timeout fired counts: + // the timer keeps running after a fetch settles. + if (timeoutSignal.aborted) { + timedOut = true + } + throw error }) }, })