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
14 changes: 14 additions & 0 deletions .changeset/did-web-resolver-fetch-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
126 changes: 121 additions & 5 deletions packages/did/src/did-resolvers/web-did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
})

Expand Down Expand Up @@ -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) },
)
})

Expand Down Expand Up @@ -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) },
)
})

Expand Down Expand Up @@ -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) },
)
})

Expand Down Expand Up @@ -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) },
)
})

Expand Down Expand Up @@ -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 () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not related to this pr in particular, but i think some test helpers could make each of these tests less verbose

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it would help. Left it out since it touches the pre-existing tests too; happy to do a small follow-up PR.

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

expect(result.didDocument).toBeNull()
expect(result.didResolutionMetadata.error).toBe("notFound")
expect(result.didResolutionMetadata.message).toContain(
"The operation timed out.",
)
})
})
})
35 changes: 32 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,10 +44,20 @@ export interface DidWebResolverOptions {
* @default []
*/
allowedHttpHosts?: string[]
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment seems a bit verbose - it seems like we could drop the rationale sentence and the mention of previous behavior, which one could see through changesets anyways. could also add a @default annotation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed and added @default 5000 in 814f8d8.

* 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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
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,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,
Expand All @@ -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")
Expand Down
Loading