Skip to content

fix(pds): address getFeed service-auth JWT to the feed generator#193

Merged
ascorbic merged 6 commits into
mainfrom
fix/getfeed-service-auth-aud
May 30, 2026
Merged

fix(pds): address getFeed service-auth JWT to the feed generator#193
ascorbic merged 6 commits into
mainfrom
fix/getfeed-service-auth-aud

Conversation

@ascorbic

@ascorbic ascorbic commented May 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • app.bsky.feed.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 — 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.
  • Adds a dedicated GET /xrpc/app.bsky.feed.getFeed route (handleGetFeedProxy) that resolves the feedgen's service DID from the feed record in the creator's repo, then proxies via handleXrpcProxy with a new ServiceAuthOverride. This mirrors the reference PDS (api/app/bsky/feed/getFeed.tspipethrough with an aud override).
  • Fail-soft: if the feed record can't be resolved, it falls back to ordinary AppView proxying so the feed still loads.

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 → record did = did:web:foryou.club, which is the aud the generator expects.

Test plan

  • New tests in 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.
  • Full PDS suite green: 304/304 tests pass.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
pdscheck 6ee4ad6 May 30 2026, 06:15 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
atproto-pds 6ee4ad6 May 30 2026, 06:15 PM

@pkg-pr-new

pkg-pr-new Bot commented May 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/create-pds@193
npm i https://pkg.pr.new/@getcirrus/oauth-provider@193
npm i https://pkg.pr.new/@getcirrus/pds@193

commit: 6ee4ad6

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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.
@ascorbic ascorbic force-pushed the fix/getfeed-service-auth-aud branch from ee419aa to 19e1968 Compare May 30, 2026 16:47
@ascorbic ascorbic requested a review from Copilot May 30, 2026 16:50

Copilot AI left a comment

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.

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.getFeed to 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 handleXrpcProxy to 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.

Comment thread packages/pds/src/xrpc-proxy.ts Outdated
Comment on lines +178 to +182
const requiredLxms = serviceLxm === lxm ? [lxm] : [lxm, serviceLxm];
const permissions = permissionsFor(tokenData.scope);
for (const requiredLxm of requiredLxms) {
permissions.assertRpc({ lxm: requiredLxm, aud: serviceAud });
}
Comment thread packages/pds/src/xrpc-proxy.ts Outdated
Comment on lines +310 to +316
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.
@ascorbic

Copy link
Copy Markdown
Owner Author

Both Copilot findings were valid — fixed in 984db54:

  1. Scope asserted at the wrong audience. The OAuth/DPoP check now asserts getFeed + getFeedSkeleton against the routing audience (audienceDid, i.e. the AppView), not the overridden feedgen aud. Asserting at the feedgen aud would have rejected valid tokens scoped rpc:app.bsky.feed.getFeed?aud=did:web:api.bsky.app. The override aud now applies only to the outbound JWT, matching the reference PDS's authorize callback (which uses computeProxyTo = the proxy target).

  2. Missing HTTPS check in feed resolution. resolveFeedGenDid now rejects non-HTTPS PDS endpoints before fetching the feed record, matching the proxy-target restriction in handleXrpcProxy, so an attacker-controlled DID document can't trigger plaintext requests. Added a test asserting the record is never fetched over http:// and the request falls back to AppView proxying.

ascorbic added 2 commits May 30, 2026 18:56
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.
@ascorbic

Copy link
Copy Markdown
Owner Author

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 worker.fetch:

  • accepts a token scoped for the AppView audience → 200, and the forwarded service JWT is addressed to the feedgen (aud = did:web:feedgen…, lxm = getFeedSkeleton).
  • rejects a token scoped for a different audience → 403 InsufficientScope.

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 rpc: scopes are granted against the full did#service_id audience (a bare DID isn't even a valid rpc scope aud in @atproto/oauth-scopes, and matching is exact), but the proxy was asserting against the bare audienceDid — so any granular (non-aud=*) OAuth scope was silently rejected on proxied calls. Now the scope check uses the full proxy-header audience (matching the reference's computeProxyTo), while the outbound JWT keeps the bare DID. This matters for granular-scope clients (e.g. Tangled, Linkat).

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread packages/pds/src/xrpc-proxy.ts Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@ascorbic ascorbic enabled auto-merge (squash) May 30, 2026 18:15
@ascorbic ascorbic merged commit be57325 into main May 30, 2026
7 checks passed
@ascorbic ascorbic deleted the fix/getfeed-service-auth-aud branch May 30, 2026 18:16
@mixie-bot mixie-bot Bot mentioned this pull request May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants