Skip to content

Commit 99fe6e8

Browse files
committed
fix: patched fetch
1 parent 7034808 commit 99fe6e8

8 files changed

Lines changed: 255 additions & 10 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export default defineConfig({
4646
})
4747
```
4848

49+
AT Astro detects runtimes that reject `RequestInit.redirect: "error"` and applies a scoped
50+
OAuth fetch compatibility patch automatically. Set `patchRedirects: true` or `false` only to
51+
force or disable that behavior for a runtime whose capability detection is inaccurate.
52+
4953
## Usage
5054

5155
### Routes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "at-astro",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"private": false,
55
"description": "An Astro integration for the AT Protocol, implementing OAuth flow and publishing an authenticated client to fetch and mutate records.",
66
"keywords": [

src/integration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type AtAstroConfig = {
1515
oauthSessionPrefix: string
1616
handleResolver: string
1717
publicEndpoint: string
18+
patchRedirects?: boolean
1819
redirectAfterSignIn: string
1920
redirectAfterSignOut: string
2021
}
@@ -40,6 +41,8 @@ export type AtAstroOptions = {
4041
* @default "https://public.api.bsky.app"
4142
*/
4243
publicEndpoint?: string
44+
/** Force-enable or disable the OAuth redirect compatibility patch; defaults to runtime detection. */
45+
patchRedirects?: boolean
4346
/** The path to redirect to after the user successfully signs in (defaults to the site's root) */
4447
redirectAfterSignIn?: `/${string}`
4548
/** The path to redirect to after the user successfully signs out (defaults to the site's root) */
@@ -104,6 +107,7 @@ export function createConfig({ isDev, astroConfig, options }: CreateConfigOption
104107
oauthSessionPrefix: `${sessionPrefix}:oauth-session:`,
105108
handleResolver: options?.handleResolver ?? "https://bsky.social",
106109
publicEndpoint: options?.publicEndpoint ?? "https://public.api.bsky.app",
110+
patchRedirects: options?.patchRedirects,
107111
redirectAfterSignIn: `${clientUri}${options?.redirectAfterSignIn ?? ""}`,
108112
redirectAfterSignOut: `${clientUri}${options?.redirectAfterSignOut ?? ""}`,
109113
}

src/lib/atproto-client.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, mock, spyOn, test } from "bun:test"
2+
import { OAuthResolverError } from "@atproto/oauth-client"
3+
import type { AtAstroSession } from "../types/session"
4+
5+
const restore = mock()
6+
7+
void mock.module("at-astro:config", () => ({
8+
config: {
9+
didSessionKey: "at-astro:did",
10+
publicEndpoint: "https://public.api.bsky.app",
11+
},
12+
}))
13+
void mock.module("./atproto-oauth", () => ({
14+
getOAuthClient: () => ({ restore }),
15+
}))
16+
17+
const { getClient } = await import("./atproto-client")
18+
const session = {
19+
get: () => Promise.resolve("did:plc:test"),
20+
} as unknown as AtAstroSession
21+
22+
test("returns a public client when identity resolution fails", async () => {
23+
const error = new OAuthResolverError("Failed to resolve identity: did:plc:test")
24+
const consoleError = spyOn(console, "error").mockImplementation(() => {})
25+
restore.mockRejectedValueOnce(error)
26+
27+
try {
28+
expect((await getClient(session)).did).toBeNull()
29+
expect(consoleError).toHaveBeenCalledWith("Failed to restore AT Protocol session", error)
30+
} finally {
31+
consoleError.mockRestore()
32+
}
33+
})
34+
35+
test("preserves other session restore errors", async () => {
36+
const error = new Error("Session storage failed")
37+
restore.mockRejectedValueOnce(error)
38+
39+
expect(getClient(session)).rejects.toBe(error)
40+
})

src/lib/atproto-client.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { Client } from "@atproto/lex"
2+
import { OAuthResolverError } from "@atproto/oauth-client"
23
import { getOAuthClient } from "./atproto-oauth"
34
import { config } from "at-astro:config"
45
import type { AtAstroSession } from "../types/session"
56

7+
function getPublicClient() {
8+
return {
9+
client: new Client(config.publicEndpoint, { validateRequest: import.meta.env.DEV }),
10+
did: null,
11+
}
12+
}
13+
614
/**
715
* Returns an ATProto client, authenticated if the user is signed in, and a public client otherwise.
816
* @param session - The session to use for authentication, typically Astro.session.
@@ -14,13 +22,14 @@ export async function getClient(session: AtAstroSession | undefined): Promise<{
1422
did: string | null
1523
}> {
1624
const did = await session?.get(config.didSessionKey)
17-
if (!session || !did) {
18-
return {
19-
client: new Client(config.publicEndpoint, { validateRequest: import.meta.env.DEV }),
20-
did: null,
21-
}
22-
}
25+
if (!session || !did) return getPublicClient()
2326

24-
const oauthSession = await getOAuthClient(session).restore(did)
25-
return { client: new Client(oauthSession, { validateRequest: import.meta.env.DEV }), did }
27+
try {
28+
const oauthSession = await getOAuthClient(session).restore(did)
29+
return { client: new Client(oauthSession, { validateRequest: import.meta.env.DEV }), did }
30+
} catch (error) {
31+
if (!(error instanceof OAuthResolverError)) throw error
32+
console.error("Failed to restore AT Protocol session", error)
33+
return getPublicClient()
34+
}
2635
}

src/lib/atproto-oauth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WebcryptoKey } from "@atproto/jwk-webcrypto"
33
import type { SimpleStore } from "@atproto-labs/simple-store"
44
import { config } from "at-astro:config"
55
import type { AtAstroSession } from "../types/session.ts"
6+
import { createOAuthFetchOptions, type Fetch } from "./workerd-fetch.ts"
67

78
async function importDpopKey(jwk: JsonWebKey) {
89
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.d) {
@@ -45,11 +46,16 @@ function oauthStore<T extends { dpopKey: Key }>(
4546
}
4647
}
4748

48-
export function getOAuthClient(session: AtAstroSession) {
49+
export function getOAuthClient(
50+
session: AtAstroSession,
51+
fetch: Fetch = globalThis.fetch,
52+
patchRedirects = config.patchRedirects,
53+
) {
4954
return new OAuthClient({
5055
responseMode: "query",
5156
clientMetadata: config.clientMetadata,
5257
handleResolver: config.handleResolver,
58+
...createOAuthFetchOptions(fetch, patchRedirects),
5359
stateStore: oauthStore(config.oauthStatePrefix, session, 3600),
5460
sessionStore: oauthStore(config.oauthSessionPrefix, session),
5561
runtimeImplementation: {

src/lib/workerd-fetch.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect, mock, test } from "bun:test"
2+
import type { Did } from "@atproto/oauth-client"
3+
import { createOAuthFetchOptions } from "./workerd-fetch"
4+
5+
const plcDid = "did:plc:3u26lcxyhiyq3ygsfyrc7xx2" as Did<"plc">
6+
7+
function createWorkerdOptions(
8+
fetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
9+
) {
10+
return createOAuthFetchOptions(fetch, true)
11+
}
12+
13+
test("uses the supplied fetch unchanged when redirect:error is supported", () => {
14+
const fetch = mock(async () => Response.json({ ok: true }))
15+
const options = createOAuthFetchOptions(fetch)
16+
17+
expect(Object.is(options.fetch, fetch)).toBe(true)
18+
expect(options.didResolver).toBeUndefined()
19+
})
20+
21+
test("patches redirect:error when the Request constructor rejects it", async () => {
22+
const fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
23+
if (init?.redirect === "error") {
24+
throw new TypeError('Invalid redirect value: "error"')
25+
}
26+
return Response.json({ id: plcDid })
27+
})
28+
const options = createWorkerdOptions(fetch)
29+
30+
expect(await options.fetch("https://plc.directory/test", { redirect: "error" })).toEqual(
31+
expect.objectContaining({ status: 200 }),
32+
)
33+
expect(fetch.mock.calls[0]?.[1]?.redirect).toBe("manual")
34+
})
35+
36+
test("patches redirect:error on a Request input", async () => {
37+
const fetch = mock(async (input: string | URL | Request) => {
38+
expect(input).toBeInstanceOf(Request)
39+
expect((input as Request).redirect).toBe("manual")
40+
return Response.json({ ok: true })
41+
})
42+
const request = new Request("https://example.com", { redirect: "error" })
43+
44+
await createWorkerdOptions(fetch).fetch(request)
45+
})
46+
47+
test("rejects redirects when patching redirect:error", async () => {
48+
const fetch = mock(async () => Response.redirect("https://attacker.example/did.json", 302))
49+
50+
expect(
51+
createWorkerdOptions(fetch).fetch("https://plc.directory/test", { redirect: "error" }),
52+
).rejects.toThrow("Redirects are not allowed")
53+
})
54+
55+
test("preserves other redirect modes", async () => {
56+
const response = Response.json({ ok: true })
57+
const fetch = mock(async (_input: string | URL | Request, _init?: RequestInit) => response)
58+
59+
expect(
60+
await createWorkerdOptions(fetch).fetch("https://example.com", { redirect: "follow" }),
61+
).toBe(response)
62+
expect(fetch.mock.calls[0]?.[1]?.redirect).toBe("follow")
63+
})
64+
65+
test("lets the ATProto DID resolver reach the patched fetch", async () => {
66+
const fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
67+
if (init?.redirect === "error") {
68+
throw new TypeError('Invalid redirect value: "error"')
69+
}
70+
return Response.json({ id: plcDid })
71+
})
72+
const options = createWorkerdOptions(fetch)
73+
74+
expect(await options.didResolver?.resolve(plcDid)).toEqual({ id: plcDid })
75+
expect(fetch.mock.calls[0]?.[1]?.redirect).toBe("manual")
76+
})
77+
78+
test("resolves did:web through the same patched fetch", async () => {
79+
const did = "did:web:example.com" as Did<"web">
80+
const fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
81+
expect(input).toBeInstanceOf(URL)
82+
expect((input as URL).href).toBe("https://example.com/.well-known/did.json")
83+
expect(init?.redirect).toBe("manual")
84+
return Response.json({ id: did })
85+
})
86+
const options = createWorkerdOptions(fetch)
87+
88+
expect(await options.didResolver?.resolve(did)).toEqual({ id: did })
89+
})

src/lib/workerd-fetch.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
DidPlcMethod,
3+
DidResolverCommon,
4+
DidWebMethod,
5+
type AtprotoIdentityDidMethods,
6+
type DidMethod,
7+
type OAuthClientOptions,
8+
} from "@atproto/oauth-client"
9+
10+
export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
11+
type OAuthFetch = NonNullable<OAuthClientOptions["fetch"]>
12+
13+
const redirectStatuses = new Set([301, 302, 303, 307, 308])
14+
15+
function createWorkerdFetch(fetch: Fetch): OAuthFetch {
16+
const patchedFetch: Fetch = async (input, init) => {
17+
const redirect = init?.redirect ?? (input instanceof Request ? input.redirect : undefined)
18+
if (redirect !== "error") return fetch.call(globalThis, input, init)
19+
20+
const patchedInit = { ...init, redirect: "manual" as const }
21+
const response =
22+
input instanceof Request
23+
? await fetch.call(globalThis, new Request(input, patchedInit))
24+
: await fetch.call(globalThis, input, patchedInit)
25+
26+
if (response.type === "opaqueredirect" || redirectStatuses.has(response.status)) {
27+
throw new TypeError("Redirects are not allowed")
28+
}
29+
30+
return response
31+
}
32+
33+
// Bun augments its global fetch type with preconnect, but ATProto only calls it as a function.
34+
return patchedFetch as OAuthFetch
35+
}
36+
37+
class WorkerdDidPlcMethod extends DidPlcMethod {
38+
protected override readonly fetch: Fetch
39+
40+
constructor(fetch: Fetch) {
41+
super()
42+
this.fetch = fetch
43+
}
44+
}
45+
46+
class WorkerdDidWebMethod extends DidWebMethod {
47+
protected override readonly fetch: Fetch
48+
49+
constructor(fetch: Fetch) {
50+
super()
51+
this.fetch = fetch
52+
}
53+
}
54+
55+
type DidMethodRegistry = {
56+
set<M extends AtprotoIdentityDidMethods>(name: M, method: DidMethod<M>): void
57+
}
58+
59+
class WorkerdDidResolver extends DidResolverCommon {
60+
constructor(fetch: Fetch) {
61+
super()
62+
// The SDK constructs Request before calling an injected fetch, which Workerd rejects for
63+
// redirect:error. Replace only the DID methods so the patched fetch receives their init.
64+
const methods = this.methods as unknown as DidMethodRegistry
65+
methods.set("plc", new WorkerdDidPlcMethod(fetch))
66+
methods.set("web", new WorkerdDidWebMethod(fetch))
67+
}
68+
}
69+
70+
function createWorkerdDidResolver(fetch: Fetch) {
71+
return new WorkerdDidResolver(fetch)
72+
}
73+
74+
function supportsRedirectError() {
75+
try {
76+
return new Request("https://at-astro.invalid", { redirect: "error" }).redirect === "error"
77+
} catch {
78+
return false
79+
}
80+
}
81+
82+
export function createOAuthFetchOptions(
83+
fetch: Fetch = globalThis.fetch,
84+
patchRedirects = !supportsRedirectError(),
85+
) {
86+
if (!patchRedirects) return { fetch: fetch as OAuthFetch }
87+
88+
const patchedFetch = createWorkerdFetch(fetch)
89+
return {
90+
fetch: patchedFetch,
91+
didResolver: createWorkerdDidResolver(patchedFetch),
92+
}
93+
}

0 commit comments

Comments
 (0)