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
15 changes: 15 additions & 0 deletions .changeset/did-jwks-fetch-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
118 changes: 118 additions & 0 deletions packages/did/src/did-resolvers/get-did-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FetchLike>(
(_input, init) =>
new Promise<Response>((_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<FetchLike>(
(input) =>
new Promise<Response>((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<FetchLike>((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<Response>((_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<FetchLike>()
.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()
}
})
})
})
67 changes: 55 additions & 12 deletions packages/did/src/did-resolvers/get-did-resolver.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const pkhResolver = getPkhDidResolver()

const didResolver = new DidResolver(
{
...keyResolver,
...webResolver,
...jwksResolver,
jwks: resolveJwks,
...pkhResolver,
},
options,
Expand Down
7 changes: 6 additions & 1 deletion packages/did/src/did-resolvers/web-did-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand Down