Skip to content

Commit be57325

Browse files
ascorbicCopilot
andauthored
fix(pds): address getFeed service-auth JWT to the feed generator (#193)
* fix(pds): address getFeed service-auth JWT to the feed generator getFeed is proxied to the AppView, but the service-auth JWT must be addressed to the feed generator (aud = feedgen DID, lxm = getFeedSkeleton) so the generator can authorize the user and record per-user state. We were stamping aud = did:web:api.bsky.app, so generators that validate the audience (e.g. Bluesky For You) rejected the token and ran statelessly, leaving feeds stuck. Add handleGetFeedProxy: resolve the feedgen DID from the feed record in the creator's repo and pass a service-auth override into handleXrpcProxy. Falls back to ordinary AppView proxying when the feed can't be resolved. * fix(pds): assert getFeed scope at routing aud and require HTTPS for feed resolution Address review feedback: - The OAuth/DPoP scope check now asserts getFeed and getFeedSkeleton against the routing audience (the AppView), not the overridden feedgen aud on the outbound JWT. Asserting at the feedgen aud rejected otherwise-valid tokens. Matches the reference PDS. - resolveFeedGenDid only fetches the feed record over HTTPS, matching the proxy-target restriction, so an attacker-controlled DID document can't trigger plaintext requests from the PDS. * fix(pds): assert proxy rpc scope at the full did#service audience Granular rpc: scopes are granted against the full `did#service_id` audience (a bare DID is not a valid rpc scope audience per @atproto/oauth-scopes), and scope matching is exact. The proxy asserted against the bare `audienceDid`, so granular OAuth scopes could only ever match `aud=*`. Assert against the full proxy-header value (matching the reference PDS's computeProxyTo) while keeping the bare DID for the outbound service-auth JWT. Add OAuth/DPoP tests covering the getFeed scope check: a token scoped for the AppView audience is accepted and the JWT is addressed to the feedgen; a token scoped for a different audience is rejected with InsufficientScope. * docs(changeset): note the granular rpc scope audience fix * docs(changeset): split proxy scope fix into its own changeset * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 32d7c04 commit be57325

5 files changed

Lines changed: 637 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@getcirrus/pds": patch
3+
---
4+
5+
Address the service-auth JWT for `app.bsky.feed.getFeed` to the feed generator rather than the AppView. The token is now stamped with `aud` set to the generator's service DID (resolved from the feed record) and `lxm` set to `app.bsky.feed.getFeedSkeleton`, matching the reference PDS implementation. Previously the token carried `aud: did:web:api.bsky.app`, so generators that validate the audience (such as the Bluesky "For You" feed) rejected it and ran in a degraded, stateless mode — feeds appeared stuck because per-user "seen" state was never recorded. If the feed record can't be resolved, the request falls back to ordinary AppView proxying so the feed still loads.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@getcirrus/pds": patch
3+
---
4+
5+
Fix OAuth scope checking when proxying XRPC requests. Granular `rpc:` scopes are granted against the full `did#service_id` audience, but the proxy was checking them against the bare DID, so any granular (non-`aud=*`) scope was rejected. Proxied requests now check scope against the full service audience, while the outbound service-auth JWT continues to use the bare DID.

packages/pds/src/index.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isDid, isHandle } from "@atcute/lexicons/syntax";
1010
import { requireAuth } from "./middleware/auth";
1111
import { DidResolver } from "./did-resolver";
1212
import { WorkersDidCache } from "./did-cache";
13-
import { handleXrpcProxy } from "./xrpc-proxy";
13+
import { handleXrpcProxy, handleGetFeedProxy } from "./xrpc-proxy";
1414
import { createOAuthApp } from "./oauth";
1515
import * as sync from "./xrpc/sync";
1616
import * as repo from "./xrpc/repo";
@@ -325,20 +325,14 @@ app.get("/xrpc/com.atproto.server.getSession", (c) =>
325325
app.post("/xrpc/com.atproto.server.deleteSession", server.deleteSession);
326326

327327
// App passwords
328-
app.post(
329-
"/xrpc/com.atproto.server.createAppPassword",
330-
requireAuth,
331-
(c) => server.createAppPassword(c, getAccountDO(c.env)),
328+
app.post("/xrpc/com.atproto.server.createAppPassword", requireAuth, (c) =>
329+
server.createAppPassword(c, getAccountDO(c.env)),
332330
);
333-
app.get(
334-
"/xrpc/com.atproto.server.listAppPasswords",
335-
requireAuth,
336-
(c) => server.listAppPasswords(c, getAccountDO(c.env)),
331+
app.get("/xrpc/com.atproto.server.listAppPasswords", requireAuth, (c) =>
332+
server.listAppPasswords(c, getAccountDO(c.env)),
337333
);
338-
app.post(
339-
"/xrpc/com.atproto.server.revokeAppPassword",
340-
requireAuth,
341-
(c) => server.revokeAppPassword(c, getAccountDO(c.env)),
334+
app.post("/xrpc/com.atproto.server.revokeAppPassword", requireAuth, (c) =>
335+
server.revokeAppPassword(c, getAccountDO(c.env)),
342336
);
343337

344338
// Account lifecycle
@@ -543,6 +537,12 @@ app.post("/passkey/delete", requireAuth, async (c) => {
543537
const oauthApp = createOAuthApp(getAccountDO);
544538
app.route("/", oauthApp);
545539

540+
// getFeed is proxied to the AppView but the service-auth JWT must be addressed
541+
// to the feed generator, so it needs special handling ahead of the catch-all.
542+
app.get("/xrpc/app.bsky.feed.getFeed", (c) =>
543+
handleGetFeedProxy(c, didResolver, getKeypair),
544+
);
545+
546546
// Proxy unhandled XRPC requests to services specified via atproto-proxy header
547547
// or fall back to Bluesky services for backward compatibility
548548
app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair));

packages/pds/src/xrpc-proxy.ts

Lines changed: 121 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@
66
import type { Context } from "hono";
77
import { DidResolver } from "./did-resolver";
88
import { getAtprotoServiceEndpoint } from "@atcute/identity";
9+
import { isDid, parseResourceUri } from "@atcute/lexicons/syntax";
910
import { createServiceJwt } from "./service-auth";
10-
import {
11-
ScopeMissingError,
12-
permissionsFor,
13-
} from "@getcirrus/oauth-provider";
11+
import { ScopeMissingError, permissionsFor } from "@getcirrus/oauth-provider";
1412
import { verifyAccessToken, TokenExpiredError } from "./session";
1513
import { getProvider } from "./oauth";
1614
import type { PDSEnv } from "./types";
@@ -37,6 +35,17 @@ export function parseProxyHeader(
3735
return { did, serviceId };
3836
}
3937

38+
/**
39+
* Override the service-auth audience and lexicon method stamped into the
40+
* outbound JWT, independently of where the request is routed. Used for
41+
* getFeed, where the request is proxied to the AppView but the token must be
42+
* addressed to the feed generator so it can authorize the user.
43+
*/
44+
export interface ServiceAuthOverride {
45+
aud: string;
46+
lxm: string;
47+
}
48+
4049
/**
4150
* Handle XRPC proxy requests
4251
* Routes requests to external services based on atproto-proxy header or lexicon namespace
@@ -45,6 +54,7 @@ export async function handleXrpcProxy(
4554
c: Context<{ Bindings: PDSEnv }>,
4655
didResolver: DidResolver,
4756
getKeypair: () => Promise<Secp256k1Keypair>,
57+
serviceAuthOverride?: ServiceAuthOverride,
4858
): Promise<Response> {
4959
// Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline")
5060
const url = new URL(c.req.url);
@@ -64,6 +74,10 @@ export async function handleXrpcProxy(
6474
// Check for atproto-proxy header for explicit service routing
6575
const proxyHeader = c.req.header("atproto-proxy");
6676
let audienceDid: string;
77+
// Audience used for OAuth scope checks: the full `did#service_id` form, since
78+
// granular `rpc:` scopes are granted against that (the bare DID never
79+
// matches). The outbound service-auth JWT uses the bare DID instead.
80+
let scopeAud: string;
6781
let targetUrl: URL;
6882

6983
if (proxyHeader) {
@@ -112,6 +126,7 @@ export async function handleXrpcProxy(
112126

113127
// Use the resolved service endpoint
114128
audienceDid = parsed.did;
129+
scopeAud = proxyHeader;
115130
targetUrl = new URL(endpoint);
116131
if (targetUrl.protocol !== "https:") {
117132
return c.json(
@@ -138,12 +153,21 @@ export async function handleXrpcProxy(
138153
// These are well-known endpoints that don't require DID resolution
139154
const isChat = lxm.startsWith("chat.bsky.");
140155
audienceDid = isChat ? "did:web:api.bsky.chat" : "did:web:api.bsky.app";
156+
scopeAud = isChat
157+
? "did:web:api.bsky.chat#bsky_chat"
158+
: "did:web:api.bsky.app#bsky_appview";
141159
const endpoint = isChat ? "https://api.bsky.chat" : "https://api.bsky.app";
142160

143161
// Construct URL safely using URL constructor
144162
targetUrl = new URL(`/xrpc/${lxm}${url.search}`, endpoint);
145163
}
146164

165+
// The outbound service JWT's audience and method may differ from the
166+
// request's routing target (see getFeed: routed to the AppView, addressed
167+
// to the feed generator).
168+
const serviceAud = serviceAuthOverride?.aud ?? audienceDid;
169+
const serviceLxm = serviceAuthOverride?.lxm ?? lxm;
170+
147171
// Verify auth and create service JWT for target service
148172
let headers: Record<string, string> = {};
149173
const auth = c.req.header("Authorization");
@@ -158,15 +182,23 @@ export async function handleXrpcProxy(
158182
const provider = getProvider(c.env);
159183
const tokenData = await provider.verifyAccessToken(c.req.raw);
160184
if (tokenData) {
185+
// Scope is asserted against the routing audience (where the client
186+
// directed the request), not the override aud on the outbound JWT.
187+
// For getFeed that means getFeed + getFeedSkeleton at the AppView,
188+
// matching the reference PDS.
189+
const requiredLxms = serviceLxm === lxm ? [lxm] : [lxm, serviceLxm];
161190
try {
162-
permissionsFor(tokenData.scope).assertRpc({ lxm, aud: audienceDid });
191+
const permissions = permissionsFor(tokenData.scope);
192+
for (const requiredLxm of requiredLxms) {
193+
permissions.assertRpc({ lxm: requiredLxm, aud: scopeAud });
194+
}
163195
userDid = tokenData.sub;
164196
} catch (err) {
165197
if (err instanceof ScopeMissingError) {
166198
return c.json(
167199
{
168200
error: "InsufficientScope",
169-
message: `Token does not grant rpc:${lxm}?aud=${audienceDid}`,
201+
message: `Token does not grant rpc for ${requiredLxms.join(", ")} at aud=${scopeAud}`,
170202
},
171203
403,
172204
);
@@ -218,8 +250,8 @@ export async function handleXrpcProxy(
218250
const keypair = await getKeypair();
219251
const serviceJwt = await createServiceJwt({
220252
iss: userDid,
221-
aud: audienceDid,
222-
lxm,
253+
aud: serviceAud,
254+
lxm: serviceLxm,
223255
keypair,
224256
});
225257
headers["Authorization"] = `Bearer ${serviceJwt}`;
@@ -266,3 +298,84 @@ export async function handleXrpcProxy(
266298

267299
return fetch(targetUrl.toString(), reqInit);
268300
}
301+
302+
/**
303+
* Resolve the service DID a feed generator runs on, given a feed AT-URI.
304+
* The feed record lives in the creator's repo and carries a `did` field
305+
* pointing at the feedgen service (e.g. did:web:foryou.club). Returns null if
306+
* the feed cannot be resolved, so callers can fall back to default proxying.
307+
*/
308+
async function resolveFeedGenDid(
309+
feed: string,
310+
didResolver: DidResolver,
311+
): Promise<string | null> {
312+
const parsed = parseResourceUri(feed);
313+
if (!parsed.ok) return null;
314+
315+
const { repo, collection, rkey } = parsed.value;
316+
if (collection !== "app.bsky.feed.generator" || !rkey) return null;
317+
if (!isDid(repo)) return null;
318+
319+
const didDoc = await didResolver.resolve(repo);
320+
if (!didDoc) return null;
321+
322+
const pds = getAtprotoServiceEndpoint(didDoc, {
323+
id: "#atproto_pds",
324+
type: "AtprotoPersonalDataServer",
325+
});
326+
if (!pds) return null;
327+
328+
// The endpoint comes from a third-party DID document; only fetch over HTTPS,
329+
// matching the proxy-target restriction in handleXrpcProxy.
330+
let recordUrl: URL;
331+
try {
332+
recordUrl = new URL("/xrpc/com.atproto.repo.getRecord", pds);
333+
} catch {
334+
return null;
335+
}
336+
if (recordUrl.protocol !== "https:") return null;
337+
338+
recordUrl.searchParams.set("repo", repo);
339+
recordUrl.searchParams.set("collection", collection);
340+
recordUrl.searchParams.set("rkey", rkey);
341+
342+
const res = await fetch(recordUrl, { redirect: "manual" });
343+
if (res.status >= 300 && res.status < 400) return null;
344+
if (!res.ok) return null;
345+
346+
const body = (await res.json()) as { value?: { did?: unknown } };
347+
const feedDid = body.value?.did;
348+
return typeof feedDid === "string" && isDid(feedDid) ? feedDid : null;
349+
}
350+
351+
/**
352+
* Proxy app.bsky.feed.getFeed.
353+
*
354+
* getFeed is routed to the AppView like any other read, but the service-auth
355+
* JWT must be addressed to the feed generator (aud = feedgen DID, lxm =
356+
* getFeedSkeleton) so the generator can authorize the user and record
357+
* per-user state. Without this, generators that validate the audience reject
358+
* the token and operate in a degraded, stateless mode. If the feed can't be
359+
* resolved we fall back to default proxying so the feed still loads.
360+
*/
361+
export async function handleGetFeedProxy(
362+
c: Context<{ Bindings: PDSEnv }>,
363+
didResolver: DidResolver,
364+
getKeypair: () => Promise<Secp256k1Keypair>,
365+
): Promise<Response> {
366+
const feed = c.req.query("feed");
367+
368+
let override: ServiceAuthOverride | undefined;
369+
if (feed) {
370+
try {
371+
const feedDid = await resolveFeedGenDid(feed, didResolver);
372+
if (feedDid) {
373+
override = { aud: feedDid, lxm: "app.bsky.feed.getFeedSkeleton" };
374+
}
375+
} catch {
376+
// Fall back to default proxying when feed resolution fails.
377+
}
378+
}
379+
380+
return handleXrpcProxy(c, didResolver, getKeypair, override);
381+
}

0 commit comments

Comments
 (0)