diff --git a/.changeset/did-web-resolver-fetch-timeout.md b/.changeset/did-web-resolver-fetch-timeout.md new file mode 100644 index 0000000..36cd320 --- /dev/null +++ b/.changeset/did-web-resolver-fetch-timeout.md @@ -0,0 +1,14 @@ +--- +"@agentcommercekit/did": minor +--- + +Add a `timeout` option to `getResolver`'s `DidWebResolverOptions` for the +`did:web` resolver, defaulting to 5000ms. Resolving a `did:web` DID fetches +the host named in the DID, so an unresponsive or slow host could otherwise +hang the caller indefinitely. The resolver passes `AbortSignal.timeout(timeout)` +to the underlying fetch; a custom `fetch` must honour `init.signal` for the +timeout to take effect. Values outside 1..2147483647 (the 32-bit +timer limit) throw a `RangeError`: beyond it, runtimes either clamp the timer +to 1ms or throw at fetch time, both of which would surface as a misleading +`notFound`. There is no first-class opt-out; passing the maximum (about 24.8 +days) effectively disables the timeout. diff --git a/packages/did/src/did-resolvers/web-did-resolver.test.ts b/packages/did/src/did-resolvers/web-did-resolver.test.ts index be05eac..b0c0616 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.test.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.test.ts @@ -60,7 +60,7 @@ describe("web-did-resolver", () => { }) expect(mockFetch).toHaveBeenCalledWith( "https://example.com/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -92,7 +92,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/custom/path/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -125,7 +125,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/.well-known/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -161,7 +161,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "https://example.com/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -197,7 +197,7 @@ describe("web-did-resolver", () => { expect(mockFetch).toHaveBeenCalledWith( "http://localhost:8787/issuers/v1/did.json", - { mode: "cors" }, + { mode: "cors", signal: expect.any(AbortSignal) }, ) }) @@ -375,5 +375,121 @@ describe("web-did-resolver", () => { expect(customFetch).toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() }) + + it("passes an abort signal built from the configured timeout", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDidDocument), + }) + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + + const did = "did:web:example.com" + const resolver = getResolver({ timeout: 5000 }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(5000) + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + expect.objectContaining({ + mode: "cors", + signal: expect.any(AbortSignal), + }), + ) + timeoutSpy.mockRestore() + }) + + it("applies the 5000ms default timeout when none is set", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDidDocument), + }) + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + + const did = "did:web:example.com" + const resolver = getResolver() + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(5000) + expect(mockFetch).toHaveBeenCalledWith( + "https://example.com/.well-known/did.json", + expect.objectContaining({ + mode: "cors", + signal: expect.any(AbortSignal), + }), + ) + timeoutSpy.mockRestore() + }) + + it("throws for an invalid timeout", () => { + expect(() => getResolver({ timeout: 0 })).toThrow(RangeError) + expect(() => getResolver({ timeout: -1 })).toThrow(RangeError) + expect(() => getResolver({ timeout: 1.5 })).toThrow(RangeError) + expect(() => getResolver({ timeout: NaN })).toThrow(RangeError) + expect(() => getResolver({ timeout: 2147483648 })).toThrow(RangeError) + }) + + it("surfaces a timed-out fetch as a notFound resolution error", async () => { + mockFetch.mockRejectedValueOnce( + new DOMException("The operation timed out.", "TimeoutError"), + ) + + const did = "did:web:example.com" + const resolver = getResolver({ timeout: 1 }) + const parsedDid: ParsedDID = { + did, + didUrl: did, + method: "web", + id: "example.com", + } + const result = await resolver.web( + did, + parsedDid, + { + resolve: + vi.fn< + (didUrl: string, options?: object) => Promise + >(), + }, + {}, + ) + + expect(result.didDocument).toBeNull() + expect(result.didResolutionMetadata.error).toBe("notFound") + expect(result.didResolutionMetadata.message).toContain( + "The operation timed out.", + ) + }) }) }) diff --git a/packages/did/src/did-resolvers/web-did-resolver.ts b/packages/did/src/did-resolvers/web-did-resolver.ts index e12f377..fdbcc25 100644 --- a/packages/did/src/did-resolvers/web-did-resolver.ts +++ b/packages/did/src/did-resolvers/web-did-resolver.ts @@ -44,10 +44,20 @@ export interface DidWebResolverOptions { * @default [] */ allowedHttpHosts?: string[] + /** + * Milliseconds to wait for the DID document fetch before aborting. Must + * be a positive integer of at most 2147483647 (the 32-bit timer limit). + * + * The timeout is applied via an `AbortSignal` on the request. A custom + * `fetch` must honour `init.signal` for it to take effect. + * @default 5000 + */ + timeout?: number } const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = [] const DEFAULT_DOC_PATH = "/.well-known/did.json" +const MAX_TIMEOUT_MS = 2147483647 /** * Get a did document from a url and validate that it is a DidDocument @@ -57,9 +67,15 @@ const DEFAULT_DOC_PATH = "/.well-known/did.json" */ async function fetchDidDocumentAtUrl( url: string | URL, - { fetch = globalThis.fetch }: { fetch?: FetchLike } = {}, + { + fetch = globalThis.fetch, + timeout, + }: { fetch?: FetchLike; timeout?: number } = {}, ): Promise { - const res = await fetch(url, { mode: "cors" }) + const res = await fetch(url, { + mode: "cors", + ...(timeout !== undefined ? { signal: AbortSignal.timeout(timeout) } : {}), + }) if (!res.ok) { throw new Error( @@ -141,7 +157,20 @@ export function getResolver({ docPath = DEFAULT_DOC_PATH, fetch = globalThis.fetch, allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS, + timeout = 5000, }: DidWebResolverOptions = {}): { web: DIDResolver } { + // Fail fast on a bad timeout rather than surfacing it later as a + // misleading `notFound` resolution error. `AbortSignal.timeout` throws on + // negative, non-integer or non-finite values; 0 is legal for the API but + // would abort every request before it starts; and values beyond the + // 32-bit timer range either clamp the timer to 1ms or throw at fetch + // time, depending on the runtime. + if (timeout <= 0 || timeout > MAX_TIMEOUT_MS || !Number.isInteger(timeout)) { + throw new RangeError( + "`timeout` must be a positive integer of at most 2147483647 milliseconds", + ) + } + async function resolve( did: string, parsed: ParsedDID, @@ -155,7 +184,7 @@ export function getResolver({ let didDocument: DIDDocument | null = null try { - didDocument = await fetchDidDocumentAtUrl(url, { fetch }) + didDocument = await fetchDidDocumentAtUrl(url, { fetch, timeout }) if (!isDidDocumentForDid(didDocument, did)) { throw new Error("DID document id does not match requested did")