Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/did-web-resolver-fetch-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@agentcommercekit/did": minor
---

Add an optional `timeout` to `getResolver`'s `DidWebResolverOptions` for the
`did:web` resolver. Resolving a `did:web` DID fetches the host named in the
DID, so an unresponsive or slow host could otherwise hang the caller
indefinitely. When set, the resolver passes `AbortSignal.timeout(timeout)` to
the underlying fetch. Omitting it keeps the previous behaviour (no timeout),
so this change is backward compatible.
110 changes: 110 additions & 0 deletions packages/did/src/did-resolvers/web-did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,5 +375,115 @@ 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<DIDResolutionResult>
>(),
},
{},
)

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("does not pass a signal when no timeout is set", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDidDocument),
})

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<DIDResolutionResult>
>(),
},
{},
)

// Exact init, so this fails if a signal (or anything else) is added.
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/did.json",
{ mode: "cors" },
)
})

it("throws for an invalid timeout", () => {
expect(() => getResolver({ timeout: 0 })).toThrow(TypeError)
expect(() => getResolver({ timeout: -1 })).toThrow(TypeError)
expect(() => getResolver({ timeout: 1.5 })).toThrow(TypeError)
expect(() => getResolver({ timeout: NaN })).toThrow(TypeError)
})

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<DIDResolutionResult>
>(),
},
{},
)

expect(result.didDocument).toBeNull()
expect(result.didResolutionMetadata.error).toBe("notFound")
expect(result.didResolutionMetadata.message).toContain(
"The operation timed out.",
)
})
})
})
30 changes: 27 additions & 3 deletions packages/did/src/did-resolvers/web-did-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ export interface DidWebResolverOptions {
* @default []
*/
allowedHttpHosts?: string[]
/**
* Milliseconds to wait for the DID document fetch before aborting. A
* `did:web` resolution fetches the host named in the DID, so an
* unresponsive host would otherwise hang the caller indefinitely. Omit to
* keep the previous behaviour (no timeout).
*
* The timeout is applied via an `AbortSignal` on the request. A custom
* `fetch` must honour `init.signal` for it to take effect.
*/
timeout?: number
}

const DEFAULT_ALLOWED_HTTP_HOSTS: string[] = []
Expand All @@ -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<DidDocument> {
const res = await fetch(url, { mode: "cors" })
const res = await fetch(url, {
mode: "cors",
...(timeout !== undefined ? { signal: AbortSignal.timeout(timeout) } : {}),
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!res.ok) {
throw new Error(
Expand Down Expand Up @@ -141,7 +157,15 @@ export function getResolver({
docPath = DEFAULT_DOC_PATH,
fetch = globalThis.fetch,
allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS,
timeout,
}: DidWebResolverOptions = {}): { web: DIDResolver } {
// Fail fast on a bad timeout: `AbortSignal.timeout` throws on non-positive,
// non-integer or non-finite values, and that would otherwise surface as a
// misleading `notFound` resolution error rather than a programmer error.
if (timeout !== undefined && (!Number.isInteger(timeout) || timeout <= 0)) {
throw new TypeError("`timeout` must be a positive integer (milliseconds)")
}

async function resolve(
did: string,
parsed: ParsedDID,
Expand All @@ -155,7 +179,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")
Expand Down