Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/getfeed-service-auth-aud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@getcirrus/pds": patch
---

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.
5 changes: 5 additions & 0 deletions .changeset/proxy-rpc-scope-audience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@getcirrus/pds": patch
---

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.
26 changes: 13 additions & 13 deletions packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { requireAuth } from "./middleware/auth";
import { DidResolver } from "./did-resolver";
import { WorkersDidCache } from "./did-cache";
import { handleXrpcProxy } from "./xrpc-proxy";
import { handleXrpcProxy, handleGetFeedProxy } from "./xrpc-proxy";
import { createOAuthApp } from "./oauth";
import * as sync from "./xrpc/sync";
import * as repo from "./xrpc/repo";
Expand Down Expand Up @@ -325,20 +325,14 @@ app.get("/xrpc/com.atproto.server.getSession", (c) =>
app.post("/xrpc/com.atproto.server.deleteSession", server.deleteSession);

// App passwords
app.post(
"/xrpc/com.atproto.server.createAppPassword",
requireAuth,
(c) => server.createAppPassword(c, getAccountDO(c.env)),
app.post("/xrpc/com.atproto.server.createAppPassword", requireAuth, (c) =>
server.createAppPassword(c, getAccountDO(c.env)),
);
app.get(
"/xrpc/com.atproto.server.listAppPasswords",
requireAuth,
(c) => server.listAppPasswords(c, getAccountDO(c.env)),
app.get("/xrpc/com.atproto.server.listAppPasswords", requireAuth, (c) =>
server.listAppPasswords(c, getAccountDO(c.env)),
);
app.post(
"/xrpc/com.atproto.server.revokeAppPassword",
requireAuth,
(c) => server.revokeAppPassword(c, getAccountDO(c.env)),
app.post("/xrpc/com.atproto.server.revokeAppPassword", requireAuth, (c) =>
server.revokeAppPassword(c, getAccountDO(c.env)),
);

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

// getFeed is proxied to the AppView but the service-auth JWT must be addressed
// to the feed generator, so it needs special handling ahead of the catch-all.
app.get("/xrpc/app.bsky.feed.getFeed", (c) =>
handleGetFeedProxy(c, didResolver, getKeypair),
);

// Proxy unhandled XRPC requests to services specified via atproto-proxy header
// or fall back to Bluesky services for backward compatibility
app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair));
Expand Down
129 changes: 121 additions & 8 deletions packages/pds/src/xrpc-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
import type { Context } from "hono";
import { DidResolver } from "./did-resolver";
import { getAtprotoServiceEndpoint } from "@atcute/identity";
import { isDid, parseResourceUri } from "@atcute/lexicons/syntax";
import { createServiceJwt } from "./service-auth";
import {
ScopeMissingError,
permissionsFor,
} from "@getcirrus/oauth-provider";
import { ScopeMissingError, permissionsFor } from "@getcirrus/oauth-provider";
import { verifyAccessToken, TokenExpiredError } from "./session";
import { getProvider } from "./oauth";
import type { PDSEnv } from "./types";
Expand All @@ -37,6 +35,17 @@ export function parseProxyHeader(
return { did, serviceId };
}

/**
* Override the service-auth audience and lexicon method stamped into the
* outbound JWT, independently of where the request is routed. Used for
* getFeed, where the request is proxied to the AppView but the token must be
* addressed to the feed generator so it can authorize the user.
*/
export interface ServiceAuthOverride {
aud: string;
lxm: string;
}

/**
* Handle XRPC proxy requests
* Routes requests to external services based on atproto-proxy header or lexicon namespace
Expand All @@ -45,6 +54,7 @@ export async function handleXrpcProxy(
c: Context<{ Bindings: PDSEnv }>,
didResolver: DidResolver,
getKeypair: () => Promise<Secp256k1Keypair>,
serviceAuthOverride?: ServiceAuthOverride,
): Promise<Response> {
// Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline")
const url = new URL(c.req.url);
Expand All @@ -64,6 +74,10 @@ export async function handleXrpcProxy(
// Check for atproto-proxy header for explicit service routing
const proxyHeader = c.req.header("atproto-proxy");
let audienceDid: string;
// Audience used for OAuth scope checks: the full `did#service_id` form, since
// granular `rpc:` scopes are granted against that (the bare DID never
// matches). The outbound service-auth JWT uses the bare DID instead.
let scopeAud: string;
let targetUrl: URL;

if (proxyHeader) {
Expand Down Expand Up @@ -112,6 +126,7 @@ export async function handleXrpcProxy(

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

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

// The outbound service JWT's audience and method may differ from the
// request's routing target (see getFeed: routed to the AppView, addressed
// to the feed generator).
const serviceAud = serviceAuthOverride?.aud ?? audienceDid;
const serviceLxm = serviceAuthOverride?.lxm ?? lxm;

// Verify auth and create service JWT for target service
let headers: Record<string, string> = {};
const auth = c.req.header("Authorization");
Expand All @@ -158,15 +182,23 @@ export async function handleXrpcProxy(
const provider = getProvider(c.env);
const tokenData = await provider.verifyAccessToken(c.req.raw);
if (tokenData) {
// Scope is asserted against the routing audience (where the client
// directed the request), not the override aud on the outbound JWT.
// For getFeed that means getFeed + getFeedSkeleton at the AppView,
// matching the reference PDS.
const requiredLxms = serviceLxm === lxm ? [lxm] : [lxm, serviceLxm];
try {
permissionsFor(tokenData.scope).assertRpc({ lxm, aud: audienceDid });
const permissions = permissionsFor(tokenData.scope);
for (const requiredLxm of requiredLxms) {
permissions.assertRpc({ lxm: requiredLxm, aud: scopeAud });
}
userDid = tokenData.sub;
} catch (err) {
if (err instanceof ScopeMissingError) {
return c.json(
{
error: "InsufficientScope",
message: `Token does not grant rpc:${lxm}?aud=${audienceDid}`,
message: `Token does not grant rpc for ${requiredLxms.join(", ")} at aud=${scopeAud}`,
},
403,
);
Expand Down Expand Up @@ -218,8 +250,8 @@ export async function handleXrpcProxy(
const keypair = await getKeypair();
const serviceJwt = await createServiceJwt({
iss: userDid,
aud: audienceDid,
lxm,
aud: serviceAud,
lxm: serviceLxm,
keypair,
});
headers["Authorization"] = `Bearer ${serviceJwt}`;
Expand Down Expand Up @@ -266,3 +298,84 @@ export async function handleXrpcProxy(

return fetch(targetUrl.toString(), reqInit);
}

/**
* Resolve the service DID a feed generator runs on, given a feed AT-URI.
* The feed record lives in the creator's repo and carries a `did` field
* pointing at the feedgen service (e.g. did:web:foryou.club). Returns null if
* the feed cannot be resolved, so callers can fall back to default proxying.
*/
async function resolveFeedGenDid(
feed: string,
didResolver: DidResolver,
): Promise<string | null> {
const parsed = parseResourceUri(feed);
if (!parsed.ok) return null;

const { repo, collection, rkey } = parsed.value;
if (collection !== "app.bsky.feed.generator" || !rkey) return null;
if (!isDid(repo)) return null;

const didDoc = await didResolver.resolve(repo);
if (!didDoc) return null;

const pds = getAtprotoServiceEndpoint(didDoc, {
id: "#atproto_pds",
type: "AtprotoPersonalDataServer",
});
if (!pds) return null;

// The endpoint comes from a third-party DID document; only fetch over HTTPS,
// matching the proxy-target restriction in handleXrpcProxy.
let recordUrl: URL;
try {
recordUrl = new URL("/xrpc/com.atproto.repo.getRecord", pds);
} catch {
return null;
}
if (recordUrl.protocol !== "https:") return null;

recordUrl.searchParams.set("repo", repo);
recordUrl.searchParams.set("collection", collection);
recordUrl.searchParams.set("rkey", rkey);

const res = await fetch(recordUrl, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) return null;
if (!res.ok) return null;

const body = (await res.json()) as { value?: { did?: unknown } };
const feedDid = body.value?.did;
return typeof feedDid === "string" && isDid(feedDid) ? feedDid : null;
}

/**
* Proxy app.bsky.feed.getFeed.
*
* getFeed is routed to the AppView like any other read, 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. Without this, generators that validate the audience reject
* the token and operate in a degraded, stateless mode. If the feed can't be
* resolved we fall back to default proxying so the feed still loads.
*/
export async function handleGetFeedProxy(
c: Context<{ Bindings: PDSEnv }>,
didResolver: DidResolver,
getKeypair: () => Promise<Secp256k1Keypair>,
): Promise<Response> {
const feed = c.req.query("feed");

let override: ServiceAuthOverride | undefined;
if (feed) {
try {
const feedDid = await resolveFeedGenDid(feed, didResolver);
if (feedDid) {
override = { aud: feedDid, lxm: "app.bsky.feed.getFeedSkeleton" };
}
} catch {
// Fall back to default proxying when feed resolution fails.
}
}

return handleXrpcProxy(c, didResolver, getKeypair, override);
}
Loading
Loading