fix(pds): address getFeed service-auth JWT to the feed generator#193
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
pdscheck | 6ee4ad6 | May 30 2026, 06:15 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
atproto-pds | 6ee4ad6 | May 30 2026, 06:15 PM |
commit: |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
cirrusdocs | 6ee4ad6 | Commit Preview URL Branch Preview URL |
May 30 2026, 06:16 PM |
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.
ee419aa to
19e1968
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes service-auth JWT minting for app.bsky.feed.getFeed so that, when proxying to the AppView, the outbound service JWT is addressed to the feed generator (correct aud and lxm) to enable per-user authorization/stateful behavior in generators that validate aud.
Changes:
- Add special-case handling for
GET /xrpc/app.bsky.feed.getFeedto resolve the feed generator DID from the feed record and override the outbound service-auth JWT (aud= feedgen DID,lxm=app.bsky.feed.getFeedSkeleton), with a fail-soft fallback. - Extend
handleXrpcProxyto support service-auth overrides for outbound JWT minting. - Add tests covering generator-audience JWT minting and fallback behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
packages/pds/src/xrpc-proxy.ts |
Adds service-auth override support, feedgen DID resolution, and a dedicated getFeed proxy handler. |
packages/pds/src/index.ts |
Routes app.bsky.feed.getFeed through the new specialized handler before the proxy catch-all. |
packages/pds/test/proxy.test.ts |
Adds test coverage to ensure correct aud/lxm stamping and fallback behavior. |
.changeset/getfeed-service-auth-aud.md |
Documents the patch-level behavior change for release notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const requiredLxms = serviceLxm === lxm ? [lxm] : [lxm, serviceLxm]; | ||
| const permissions = permissionsFor(tokenData.scope); | ||
| for (const requiredLxm of requiredLxms) { | ||
| permissions.assertRpc({ lxm: requiredLxm, aud: serviceAud }); | ||
| } |
| const pds = getAtprotoServiceEndpoint(didDoc, { | ||
| id: "#atproto_pds", | ||
| type: "AtprotoPersonalDataServer", | ||
| }); | ||
| if (!pds) return null; | ||
|
|
||
| const recordUrl = new URL("/xrpc/com.atproto.repo.getRecord", pds); |
…eed 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.
|
Both Copilot findings were valid — fixed in 984db54:
|
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.
|
Added the OAuth/DPoP test harness for the scope-check path (f529a4d). It mints a DPoP-bound access token, stores it in the account DO, and signs a real DPoP proof, so the getFeed scope assertion is exercised end-to-end through
Teeth-check: reverting the assertion to the feedgen aud makes the positive test fail, confirming it guards the fix. Building this surfaced a deeper pre-existing bug: granular |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Summary
app.bsky.feed.getFeedis 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 stampingaud: did:web:api.bsky.app, so generators that validate the audience — notably the Bluesky "For You" feed (did:web:foryou.club) — reject the token and run in a degraded, stateless mode. The symptom: feeds appear stuck/recycling because "seen" state is never recorded.GET /xrpc/app.bsky.feed.getFeedroute (handleGetFeedProxy) that resolves the feedgen's service DID from the feed record in the creator's repo, then proxies viahandleXrpcProxywith a newServiceAuthOverride. This mirrors the reference PDS (api/app/bsky/feed/getFeed.ts→pipethroughwith anaudoverride).Why
This is a latent bug — we never set the feedgen aud for getFeed — so any feed generator that validates the token audience for per-user state has been silently degraded. It affects every self-hosted PDS lacking this override, not just one account. Confirmed real-world values:
at://…/app.bsky.feed.generator/for-you→ recorddid=did:web:foryou.club, which is the aud the generator expects.Test plan
test/proxy.test.ts: the forwarded service JWT is addressed to the generator (aud= generator DID,lxm=getFeedSkeleton); and it falls back to the appview aud when the record can't be resolved.