diff --git a/.changeset/did-jwks-fetch-timeout.md b/.changeset/did-jwks-fetch-timeout.md new file mode 100644 index 0000000..6002115 --- /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. 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 c5395e7..b7960f7 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,122 @@ 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).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: [ + { + 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 () => { + 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 f793c8a..d6797a6 100644 --- a/packages/did/src/did-resolvers/get-did-resolver.ts +++ b/packages/did/src/did-resolvers/get-did-resolver.ts @@ -1,17 +1,19 @@ -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" 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,24 +31,65 @@ 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 - const jwksResolver = getJwksDidResolver({ - ...webOptions, - fetch: (input, init) => - webFetch(input, { - ...init, - redirect: webOptions.followRedirects ? "follow" : "manual", - }), - }) + // 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. + // 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) + 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 + }) + }, + }) + 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, diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index 7158593..fb95ce2 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