diff --git a/.changeset/getfeed-service-auth-aud.md b/.changeset/getfeed-service-auth-aud.md new file mode 100644 index 00000000..3f454af2 --- /dev/null +++ b/.changeset/getfeed-service-auth-aud.md @@ -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. diff --git a/.changeset/proxy-rpc-scope-audience.md b/.changeset/proxy-rpc-scope-audience.md new file mode 100644 index 00000000..63ec25e4 --- /dev/null +++ b/.changeset/proxy-rpc-scope-audience.md @@ -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. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 3d39e510..fc0b4acf 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -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"; @@ -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 @@ -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)); diff --git a/packages/pds/src/xrpc-proxy.ts b/packages/pds/src/xrpc-proxy.ts index 0e175925..9aee67e4 100644 --- a/packages/pds/src/xrpc-proxy.ts +++ b/packages/pds/src/xrpc-proxy.ts @@ -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"; @@ -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 @@ -45,6 +54,7 @@ export async function handleXrpcProxy( c: Context<{ Bindings: PDSEnv }>, didResolver: DidResolver, getKeypair: () => Promise, + serviceAuthOverride?: ServiceAuthOverride, ): Promise { // Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline") const url = new URL(c.req.url); @@ -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) { @@ -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( @@ -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 = {}; const auth = c.req.header("Authorization"); @@ -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, ); @@ -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}`; @@ -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 { + 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, +): Promise { + 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); +} diff --git a/packages/pds/test/proxy.test.ts b/packages/pds/test/proxy.test.ts index 7ab5a328..512bdfd1 100644 --- a/packages/pds/test/proxy.test.ts +++ b/packages/pds/test/proxy.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, beforeAll, vi, afterEach } from "vitest"; -import { env, worker } from "./helpers"; +import { base64url, calculateJwkThumbprint, SignJWT } from "jose"; +import { env, runInDurableObject, worker } from "./helpers"; +import type { AccountDurableObject } from "../src/account-do"; // Mock DID documents for testing // Note: @context is required by @atcute/identity-resolver validation @@ -270,6 +272,268 @@ describe("XRPC Service Proxying", () => { }); }); + describe("getFeed service auth", () => { + const feedUri = + "at://did:web:creator.example.com/app.bsky.feed.generator/for-you"; + + const appviewDidDoc = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:appview.example.com", + service: [ + { + id: "#bsky_appview", + type: "BskyAppView", + serviceEndpoint: "https://appview.example.com", + }, + ], + }; + + function decodeJwtPayload(authHeader: string | null): any { + expect(authHeader).toMatch(/^Bearer /); + const payloadB64 = authHeader!.slice(7).split(".")[1]!; + return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + } + + it("mints the service JWT with aud of the feed generator, not the appview", async () => { + let capturedAuth: string | null = null; + + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u === "https://creator.example.com/.well-known/did.json") { + return Promise.resolve( + new Response( + JSON.stringify({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:creator.example.com", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://creator-pds.example.com", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if ( + u.startsWith( + "https://creator-pds.example.com/xrpc/com.atproto.repo.getRecord", + ) + ) { + return Promise.resolve( + new Response( + JSON.stringify({ + uri: feedUri, + value: { + $type: "app.bsky.feed.generator", + did: "did:web:feedgen.example.com", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if (u === "https://appview.example.com/.well-known/did.json") { + return Promise.resolve( + new Response(JSON.stringify(appviewDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if ( + u.startsWith( + "https://appview.example.com/xrpc/app.bsky.feed.getFeed", + ) + ) { + capturedAuth = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + new Response(JSON.stringify({ feed: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return originalFetch(url, init); + }), + ); + + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(feedUri)}&limit=30`, + { + headers: { + "atproto-proxy": "did:web:appview.example.com#bsky_appview", + Authorization: `Bearer ${authToken}`, + }, + }, + ), + env, + ); + + expect(response.status).toBe(200); + const payload = decodeJwtPayload(capturedAuth); + expect(payload.aud).toBe("did:web:feedgen.example.com"); + expect(payload.lxm).toBe("app.bsky.feed.getFeedSkeleton"); + }); + + it("falls back to the appview aud when the feed record cannot be resolved", async () => { + let capturedAuth: string | null = null; + + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u === "https://creator.example.com/.well-known/did.json") { + return Promise.resolve( + new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if (u === "https://appview.example.com/.well-known/did.json") { + return Promise.resolve( + new Response(JSON.stringify(appviewDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if ( + u.startsWith( + "https://appview.example.com/xrpc/app.bsky.feed.getFeed", + ) + ) { + capturedAuth = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + new Response(JSON.stringify({ feed: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return originalFetch(url, init); + }), + ); + + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(feedUri)}&limit=30`, + { + headers: { + "atproto-proxy": "did:web:appview.example.com#bsky_appview", + Authorization: `Bearer ${authToken}`, + }, + }, + ), + env, + ); + + expect(response.status).toBe(200); + const payload = decodeJwtPayload(capturedAuth); + expect(payload.aud).toBe("did:web:appview.example.com"); + expect(payload.lxm).toBe("app.bsky.feed.getFeed"); + }); + + it("does not resolve the feed over a non-HTTPS creator PDS endpoint", async () => { + let capturedAuth: string | null = null; + let recordFetched = false; + + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u === "https://creator.example.com/.well-known/did.json") { + return Promise.resolve( + new Response( + JSON.stringify({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:creator.example.com", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "http://creator-pds.example.com", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if (u.startsWith("http://creator-pds.example.com")) { + recordFetched = true; + return Promise.resolve( + new Response( + JSON.stringify({ + value: { did: "did:web:feedgen.example.com" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if (u === "https://appview.example.com/.well-known/did.json") { + return Promise.resolve( + new Response(JSON.stringify(appviewDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if ( + u.startsWith( + "https://appview.example.com/xrpc/app.bsky.feed.getFeed", + ) + ) { + capturedAuth = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + new Response(JSON.stringify({ feed: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return originalFetch(url, init); + }), + ); + + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(feedUri)}&limit=30`, + { + headers: { + "atproto-proxy": "did:web:appview.example.com#bsky_appview", + Authorization: `Bearer ${authToken}`, + }, + }, + ), + env, + ); + + expect(response.status).toBe(200); + expect(recordFetched).toBe(false); + const payload = decodeJwtPayload(capturedAuth); + expect(payload.aud).toBe("did:web:appview.example.com"); + }); + }); + describe("Fallback behavior", () => { it("should proxy getRecord with foreign DID to AppView", async () => { vi.stubGlobal( @@ -491,4 +755,232 @@ describe("XRPC Service Proxying", () => { expect(capturedAuthHeader).not.toBe(`Bearer ${authToken}`); }); }); + + describe("getFeed OAuth/DPoP scope check", () => { + const feedUri = + "at://did:web:creator.example.com/app.bsky.feed.generator/for-you"; + const proxyHeader = "did:web:appview.example.com#bsky_appview"; + + const appviewDidDoc = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:appview.example.com", + service: [ + { + id: "#bsky_appview", + type: "BskyAppView", + serviceEndpoint: "https://appview.example.com", + }, + ], + }; + + async function generateEs256() { + const kp = (await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const publicJwk = (await crypto.subtle.exportKey( + "jwk", + kp.publicKey, + )) as JsonWebKey; + delete publicJwk.key_ops; + delete publicJwk.ext; + return { privateKey: kp.privateKey, publicJwk }; + } + + async function makeDpopProof( + privateKey: CryptoKey, + publicJwk: JsonWebKey, + accessToken: string, + requestUrl: string, + ): Promise { + const u = new URL(requestUrl); + const ath = base64url.encode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ), + ), + ); + return new SignJWT({ htm: "GET", htu: u.origin + u.pathname, ath }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk as Record, + }) + .setIssuedAt() + .setJti(base64url.encode(crypto.getRandomValues(new Uint8Array(16)))) + .sign(privateKey); + } + + async function storeToken( + accessToken: string, + scope: string, + dpopJkt: string, + ): Promise { + const stub = env.ACCOUNT.get(env.ACCOUNT.idFromName("account")); + await runInDurableObject(stub, async (instance: AccountDurableObject) => { + await instance.rpcSaveTokens({ + accessToken, + refreshToken: `refresh-${accessToken}`, + clientId: "did:web:client.example.com", + sub: env.DID, + scope, + dpopJkt, + issuedAt: Date.now(), + accessExpiresAt: Date.now() + 3600_000, + refreshExpiresAt: Date.now() + 90 * 24 * 3600_000, + }); + }); + } + + function stubFeedFetch(capture: { auth: string | null }): void { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u === "https://creator.example.com/.well-known/did.json") { + return Promise.resolve( + new Response( + JSON.stringify({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:creator.example.com", + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: "https://creator-pds.example.com", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if ( + u.startsWith( + "https://creator-pds.example.com/xrpc/com.atproto.repo.getRecord", + ) + ) { + return Promise.resolve( + new Response( + JSON.stringify({ + value: { did: "did:web:feedgen.example.com" }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + } + if (u === "https://appview.example.com/.well-known/did.json") { + return Promise.resolve( + new Response(JSON.stringify(appviewDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + if ( + u.startsWith( + "https://appview.example.com/xrpc/app.bsky.feed.getFeed", + ) + ) { + capture.auth = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + new Response(JSON.stringify({ feed: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return originalFetch(url, init); + }), + ); + } + + async function getFeedRequest( + accessToken: string, + privateKey: CryptoKey, + publicJwk: JsonWebKey, + ): Promise { + const requestUrl = `http://pds.test/xrpc/app.bsky.feed.getFeed?feed=${encodeURIComponent(feedUri)}&limit=30`; + const dpop = await makeDpopProof( + privateKey, + publicJwk, + accessToken, + requestUrl, + ); + return worker.fetch( + new Request(requestUrl, { + headers: { + "atproto-proxy": proxyHeader, + Authorization: `DPoP ${accessToken}`, + DPoP: dpop, + }, + }), + env, + ); + } + + it("accepts a token scoped for the AppView audience and addresses the JWT to the feedgen", async () => { + const { privateKey, publicJwk } = await generateEs256(); + const dpopJkt = await calculateJwkThumbprint( + publicJwk as Parameters[0], + "sha256", + ); + const accessToken = "tok-getfeed-valid"; + await storeToken( + accessToken, + `atproto rpc:app.bsky.feed.getFeed?aud=${proxyHeader} rpc:app.bsky.feed.getFeedSkeleton?aud=${proxyHeader}`, + dpopJkt, + ); + + const capture: { auth: string | null } = { auth: null }; + stubFeedFetch(capture); + + const response = await getFeedRequest(accessToken, privateKey, publicJwk); + + expect(response.status).toBe(200); + expect(capture.auth).toMatch(/^Bearer /); + const payload = JSON.parse( + Buffer.from( + capture.auth!.slice(7).split(".")[1]!, + "base64url", + ).toString(), + ); + expect(payload.aud).toBe("did:web:feedgen.example.com"); + expect(payload.lxm).toBe("app.bsky.feed.getFeedSkeleton"); + }); + + it("rejects a token scoped only for a different audience", async () => { + const { privateKey, publicJwk } = await generateEs256(); + const dpopJkt = await calculateJwkThumbprint( + publicJwk as Parameters[0], + "sha256", + ); + const accessToken = "tok-getfeed-wrong-aud"; + const otherAud = "did:web:other.example.com#bsky_appview"; + await storeToken( + accessToken, + `atproto rpc:app.bsky.feed.getFeed?aud=${otherAud} rpc:app.bsky.feed.getFeedSkeleton?aud=${otherAud}`, + dpopJkt, + ); + + const capture: { auth: string | null } = { auth: null }; + stubFeedFetch(capture); + + const response = await getFeedRequest(accessToken, privateKey, publicJwk); + + expect(response.status).toBe(403); + expect(capture.auth).toBeNull(); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InsufficientScope"); + }); + }); });