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/wild-hounds-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@agentcommercekit/did": minor
---

Refuse redirects when resolving did:web and did:jwks documents by default

`allowedHttpHosts` is checked against the URL built from the DID, but the
fetch followed redirects, so a redirect could move the request to a host or
scheme that check would have rejected. DID documents are served directly at
a well-known path, so the resolvers now send `redirect: "manual"`. The
did:web resolver refuses any redirect response with a precise error that
names the redirect target when the runtime exposes it (Node does; browsers
surface an opaque redirect without one); a redirected did:jwks resolution
fails as `notFound`. Set `followRedirects: true` to restore the previous
behavior.
59 changes: 59 additions & 0 deletions packages/did/src/did-resolvers/get-did-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "vitest"

import type { FetchLike } from "../types"
import { getDidResolver } from "./get-did-resolver"

describe("getDidResolver", () => {
describe("did:jwks redirect policy", () => {
it("refuses redirects by default when resolving did:jwks", async () => {
const mockFetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 302 }))

const resolver = getDidResolver({ webOptions: { fetch: mockFetch } })
const result = await resolver.resolve("did:jwks:example.com")

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/jwks.json",
expect.objectContaining({ redirect: "manual" }),
)
expect(result.didDocument).toBeNull()
expect(result.didResolutionMetadata.error).toBe("notFound")
})

it("follows redirects for did:jwks when followRedirects is true", async () => {
const mockFetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 404 }))

const resolver = getDidResolver({
webOptions: { fetch: mockFetch, followRedirects: true },
})
await resolver.resolve("did:jwks:example.com")

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/jwks.json",
expect.objectContaining({ redirect: "follow" }),
)
})

it("applies the redirect policy to the global fetch when no custom fetch is given", async () => {
const mockFetch = vi
.fn<FetchLike>()
.mockResolvedValue(new Response(null, { status: 302 }))
vi.stubGlobal("fetch", mockFetch)

try {
const resolver = getDidResolver()
await resolver.resolve("did:jwks:example.com")

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/jwks.json",
expect.objectContaining({ redirect: "manual" }),
)
} finally {
vi.unstubAllGlobals()
}
})
})
})
9 changes: 7 additions & 2 deletions packages/did/src/did-resolvers/get-did-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@ export function getDidResolver({
},
...options
}: GetDidResolverOptions = {}): DidResolver {
const webFetch = webOptions.fetch
const webFetch = webOptions.fetch ?? globalThis.fetch
const keyResolver = getKeyDidResolver()
const webResolver = getWebDidResolver(webOptions)
// did-jwks calls fetch with no init, so inject the redirect policy here
const jwksResolver = getJwksDidResolver({
...webOptions,
fetch: webFetch ? (input, init) => webFetch(input, init) : globalThis.fetch,
fetch: (input, init) =>
webFetch(input, {
...init,
redirect: webOptions.followRedirects ? "follow" : "manual",
}),
})
const pkhResolver = getPkhDidResolver()

Expand Down
142 changes: 137 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,11 @@ describe("web-did-resolver", () => {
})
expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/did.json",
{ mode: "cors", signal: expect.any(AbortSignal) },
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
})

Expand Down Expand Up @@ -92,7 +96,11 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/custom/path/did.json",
{ mode: "cors", signal: expect.any(AbortSignal) },
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
})

Expand Down Expand Up @@ -125,7 +133,11 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8787/.well-known/did.json",
{ mode: "cors", signal: expect.any(AbortSignal) },
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
})

Expand Down Expand Up @@ -161,7 +173,11 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"https://example.com/issuers/v1/did.json",
{ mode: "cors", signal: expect.any(AbortSignal) },
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
})

Expand Down Expand Up @@ -197,7 +213,11 @@ describe("web-did-resolver", () => {

expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:8787/issuers/v1/did.json",
{ mode: "cors", signal: expect.any(AbortSignal) },
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
})

Expand Down Expand Up @@ -345,6 +365,118 @@ describe("web-did-resolver", () => {
})
})

it("refuses redirects by default and reports the redirect target", async () => {
mockFetch.mockResolvedValueOnce({
status: 302,
headers: {
get: (name: string) =>
name === "location" ? "http://internal.host/did.json" : null,
},
})

const did = "did:web:example.com"
const resolver = getResolver()
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(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/did.json",
{
mode: "cors",
redirect: "manual",
signal: expect.any(AbortSignal),
},
)
expect(result.didResolutionMetadata.error).toBe("notFound")
expect(result.didResolutionMetadata.message).toBe(
"resolver_error: DID resolution refused a redirect to http://internal.host/did.json. Set followRedirects: true to allow redirects.",
)
})

it("refuses an opaque browser redirect without a target", async () => {
mockFetch.mockResolvedValueOnce({
type: "opaqueredirect",
status: 0,
headers: { get: () => null },
})

const did = "did:web:example.com"
const resolver = getResolver()
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.didResolutionMetadata.error).toBe("notFound")
expect(result.didResolutionMetadata.message).toBe(
"resolver_error: DID resolution refused a redirect. Set followRedirects: true to allow redirects.",
)
})

it("follows redirects when followRedirects is true", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDidDocument),
})

const did = "did:web:example.com"
const resolver = getResolver({ followRedirects: true })
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(mockFetch).toHaveBeenCalledWith(
"https://example.com/.well-known/did.json",
{
mode: "cors",
redirect: "follow",
signal: expect.any(AbortSignal),
},
)
})

it("uses custom fetch function when provided", async () => {
const customFetch = vi.fn<FetchLike>().mockResolvedValueOnce(
new Response(JSON.stringify(mockDidDocument), {
Expand Down
40 changes: 38 additions & 2 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,19 @@ export interface DidWebResolverOptions {
* @default []
*/
allowedHttpHosts?: string[]
/**
* Whether to follow HTTP redirects while fetching the did document.
*
* The `allowedHttpHosts` check applies to the resolved URL only, so a
* followed redirect can move the request to a host or scheme that check
* would have rejected. did:web documents are served directly at a
* well-known path, so redirects are refused by default.
*
* The policy is applied via `init.redirect` on the request. A custom
* `fetch` must honour `init.redirect` for it to take effect.
* @default false
*/
followRedirects?: boolean
Comment on lines +47 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small / Optional polish: The followRedirects doc does not say that a custom fetch must honour init.redirect. A custom fetch that ignores it silently follows redirects, and the 3xx check at line 100-103 never fires. The timeout option (line 62-63) already carries the equivalent note for init.signal.

Suggested change
/**
* Whether to follow HTTP redirects while fetching the did document.
*
* The `allowedHttpHosts` check applies to the resolved URL only, so a
* followed redirect can move the request to a host or scheme that check
* would have rejected. did:web documents are served directly at a
* well-known path, so redirects are refused by default.
*
* @default false
*/
followRedirects?: boolean
/**
* Whether to follow HTTP redirects while fetching the did document.
*
* The `allowedHttpHosts` check applies to the resolved URL only, so a
* followed redirect can move the request to a host or scheme that check
* would have rejected. did:web documents are served directly at a
* well-known path, so redirects are refused by default.
*
* A custom `fetch` must honour `init.redirect` for this to take effect.
*
* @default false
*/

/**
* Milliseconds to wait for the DID document fetch before aborting. Must
* be a positive integer of at most 2147483647 (the 32-bit timer limit).
Expand All @@ -69,14 +82,32 @@ async function fetchDidDocumentAtUrl(
url: string | URL,
{
fetch = globalThis.fetch,
followRedirects = false,
timeout,
}: { fetch?: FetchLike; timeout?: number } = {},
}: {
fetch?: FetchLike
followRedirects?: boolean
timeout?: number
} = {},
): Promise<DidDocument> {
const res = await fetch(url, {
mode: "cors",
redirect: followRedirects ? "follow" : "manual",
...(timeout !== undefined ? { signal: AbortSignal.timeout(timeout) } : {}),
})

// browsers surface manual redirects as an opaque response with status 0
if (
!followRedirects &&
(res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400))
) {
const location = res.headers.get("location")
const target = location ? ` to ${location}` : ""
throw new Error(
`DID resolution refused a redirect${target}. Set followRedirects: true to allow redirects.`,
)
}

if (!res.ok) {
throw new Error(
`DID must resolve to a valid https URL containing a JSON document: Bad response ${res.statusText}`,
Expand Down Expand Up @@ -157,6 +188,7 @@ export function getResolver({
docPath = DEFAULT_DOC_PATH,
fetch = globalThis.fetch,
allowedHttpHosts = DEFAULT_ALLOWED_HTTP_HOSTS,
followRedirects = false,
timeout = 5000,
}: DidWebResolverOptions = {}): { web: DIDResolver } {
// Fail fast on a bad timeout rather than surfacing it later as a
Expand Down Expand Up @@ -184,7 +216,11 @@ export function getResolver({
let didDocument: DIDDocument | null = null

try {
didDocument = await fetchDidDocumentAtUrl(url, { fetch, timeout })
didDocument = await fetchDidDocumentAtUrl(url, {
fetch,
followRedirects,
timeout,
})

if (!isDidDocumentForDid(didDocument, did)) {
throw new Error("DID document id does not match requested did")
Expand Down