A minimal reproduction of issues with the aud parameter within the app.bsky.authViewAll permission set
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

expand a bit

+227 -62
+102 -62
src/server.ts
··· 14 14 * so every call sends `atproto-proxy: did:web:api.bsky.app#bsky_appview`. 15 15 * 16 16 * Expected: every tested RPC (which are all in the permission set) succeeds. 17 - * Observed: most RPCs succeed but `app.bsky.feed.getFeedGenerator` fails with 18 - * 19 - * XRPCError: Missing required scope 20 - * "rpc:app.bsky.feed.getFeedGenerator?aud=did:web:api.bsky.app" 17 + * Observed: three RPCs fail — 18 + * app.bsky.graph.getLists 19 + * app.bsky.notification.listNotifications 20 + * app.bsky.feed.getFeedGenerator 21 + * — each with XRPCError: Missing required scope 22 + * "rpc:<nsid>?aud=did:web:api.bsky.app" 21 23 * 22 24 * The required scope reported by the AS uses the BARE appview DID (no 23 25 * `#bsky_appview` service fragment), ignoring the `atproto-proxy` target. ··· 25 27 * Bluesky dedupes two `include:` tokens with the same NSID, keeping only 26 28 * the fragmented-aud form. 27 29 * 30 + * To answer the Bluesky team's debugging questions this file also: 31 + * - prints the granted scope/aud/iss from the token response on /callback 32 + * - wraps globalThis.fetch to log the outgoing `atproto-proxy` header for 33 + * every xrpc call (confirming service proxying, not getServiceAuth) 34 + * - surfaces XRPCError.status/error/headers in the /test results table 35 + * 28 36 * Usage: 29 37 * pnpm install 30 38 * pnpm dev 31 39 * # visit http://127.0.0.1:3000/ 32 40 */ 33 41 34 - import { Agent } from "@atproto/api"; 42 + import { Agent, XRPCError } from "@atproto/api"; 35 43 import { JoseKey } from "@atproto/jwk-jose"; 36 44 import { 37 45 NodeOAuthClient, ··· 43 51 import { serve } from "@hono/node-server"; 44 52 import { Hono } from "hono"; 45 53 import { exportPKCS8, generateKeyPair } from "jose"; 54 + import { 55 + type GrantedTokenInfo, 56 + type TestResult, 57 + renderHome, 58 + renderNotLoggedIn, 59 + renderResults, 60 + } from "./templates.js"; 61 + 62 + // Wrap globalThis.fetch so every xrpc call logs its outgoing atproto-proxy 63 + // header and the last value is retrievable per-NSID. This is how we confirm 64 + // service proxying (vs getServiceAuth) and that the full `did#service` ref 65 + // is being sent. 66 + const proxyHeaderByNsid = new Map<string, string | null>(); 67 + const originalFetch = globalThis.fetch; 68 + globalThis.fetch = async (input, init) => { 69 + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; 70 + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); 71 + const proxy = headers.get("atproto-proxy"); 72 + const xrpcMatch = url.match(/\/xrpc\/([a-zA-Z0-9.]+)/); 73 + if (xrpcMatch) { 74 + const nsid = xrpcMatch[1]; 75 + proxyHeaderByNsid.set(nsid, proxy); 76 + console.log(`[fetch] ${nsid} atproto-proxy: ${proxy ?? "<unset>"}`); 77 + } 78 + return originalFetch(input, init); 79 + }; 46 80 47 81 const PORT = 3000; 48 82 const BASE_URL = `http://127.0.0.1:${PORT}`; ··· 111 145 112 146 // Track the most-recently-logged-in DID so /test can restore without a cookie. 113 147 let lastDid: string | null = null; 148 + let grantedTokenInfo: GrantedTokenInfo | null = null; 114 149 115 150 const app = new Hono(); 116 151 117 - app.get("/", (c) => { 118 - return c.html(`<!doctype html> 119 - <meta charset="utf-8" /> 120 - <title>authViewAll aud-bug repro</title> 121 - <style>body{font-family:system-ui;max-width:40em;margin:2em auto;padding:0 1em;line-height:1.5}input,button{font-size:1em;padding:.4em}code{background:#eee;padding:.15em .35em;border-radius:3px}</style> 122 - <h1>authViewAll aud enforcement repro</h1> 123 - <p>Scope: <code>${SCOPE}</code></p> 124 - <form action="/login" method="POST"> 125 - <input name="handle" placeholder="your.bsky.social handle" required /> 126 - <button>Log in with Bluesky</button> 127 - </form> 128 - <p>After login you will be redirected to <a href="/test">/test</a>, which calls several RPCs and reports results.</p>`); 129 - }); 152 + app.get("/", (c) => c.html(renderHome(SCOPE))); 130 153 131 154 app.post("/login", async (c) => { 132 155 const form = await c.req.parseBody(); ··· 140 163 const url = new URL(c.req.url); 141 164 const { session } = await client.callback(url.searchParams); 142 165 lastDid = session.did; 166 + const info = await session.getTokenInfo(); 167 + grantedTokenInfo = { scope: info.scope, aud: info.aud, iss: info.iss }; 168 + console.log("[callback] granted token info:", grantedTokenInfo); 143 169 return c.redirect("/test"); 144 170 }); 145 171 146 172 app.get("/jwks.json", (c) => c.json(client.jwks)); 147 173 app.get("/client-metadata.json", (c) => c.json(client.clientMetadata)); 148 174 149 - interface TestResult { 150 - lxm: string; 151 - status: "OK" | "FAIL"; 152 - detail?: string; 153 - } 154 - 155 175 app.get("/test", async (c) => { 156 176 if (!lastDid) { 157 - return c.html(`<p>Not logged in. <a href="/">Log in</a> first.</p>`, 401); 177 + return c.html(renderNotLoggedIn(), 401); 158 178 } 159 179 160 180 const oauthSession = await client.restore(lastDid); ··· 165 185 166 186 const results: TestResult[] = []; 167 187 const run = async (lxm: string, fn: () => Promise<unknown>) => { 188 + proxyHeaderByNsid.delete(lxm); 168 189 try { 169 190 await fn(); 170 - results.push({ lxm, status: "OK" }); 191 + results.push({ 192 + lxm, 193 + status: "OK", 194 + proxyHeader: proxyHeaderByNsid.get(lxm) ?? null, 195 + }); 171 196 } catch (e) { 172 197 const detail = 173 198 e instanceof Error ? `${e.constructor.name}: ${e.message}` : String(e); 174 - results.push({ lxm, status: "FAIL", detail }); 199 + const xrpcErr = e instanceof XRPCError ? e : null; 200 + results.push({ 201 + lxm, 202 + status: "FAIL", 203 + detail, 204 + proxyHeader: proxyHeaderByNsid.get(lxm) ?? null, 205 + httpStatus: xrpcErr?.status, 206 + errorCode: xrpcErr?.error, 207 + errorHeaders: xrpcErr?.headers, 208 + }); 175 209 } 176 210 }; 177 211 178 212 // Every RPC below is listed in the app.bsky.authViewAll permission set. 213 + const whatsHot = 214 + "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 215 + 179 216 await run("app.bsky.actor.getProfile", () => 180 217 agent.app.bsky.actor.getProfile({ actor: lastDid! }), 181 218 ); 219 + await run("app.bsky.notification.listNotifications", () => 220 + agent.app.bsky.notification.listNotifications({ limit: 1 }), 221 + ); 222 + 223 + // Feed RPCs 182 224 await run("app.bsky.feed.getTimeline", () => 183 225 agent.app.bsky.feed.getTimeline({ limit: 1 }), 184 226 ); 185 227 await run("app.bsky.feed.getAuthorFeed", () => 186 228 agent.app.bsky.feed.getAuthorFeed({ actor: lastDid!, limit: 1 }), 187 229 ); 188 - await run("app.bsky.graph.getLists", () => 189 - agent.app.bsky.graph.getLists({ actor: lastDid!, limit: 1 }), 230 + await run("app.bsky.feed.getActorFeeds", () => 231 + agent.app.bsky.feed.getActorFeeds({ actor: lastDid!, limit: 1 }), 232 + ); 233 + await run("app.bsky.feed.getActorLikes", () => 234 + agent.app.bsky.feed.getActorLikes({ actor: lastDid!, limit: 1 }), 235 + ); 236 + await run("app.bsky.feed.getSuggestedFeeds", () => 237 + agent.app.bsky.feed.getSuggestedFeeds({ limit: 1 }), 190 238 ); 191 - await run("app.bsky.notification.listNotifications", () => 192 - agent.app.bsky.notification.listNotifications({ limit: 1 }), 239 + await run("app.bsky.feed.searchPosts", () => 240 + agent.app.bsky.feed.searchPosts({ q: "hello", limit: 1 }), 193 241 ); 194 - // "What's Hot" is a well-known public feed. 242 + await run("app.bsky.feed.getFeed", () => 243 + agent.app.bsky.feed.getFeed({ feed: whatsHot, limit: 1 }), 244 + ); 195 245 await run("app.bsky.feed.getFeedGenerator", () => 196 - agent.app.bsky.feed.getFeedGenerator({ 197 - feed: "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot", 198 - }), 246 + agent.app.bsky.feed.getFeedGenerator({ feed: whatsHot }), 247 + ); 248 + await run("app.bsky.feed.getFeedGenerators", () => 249 + agent.app.bsky.feed.getFeedGenerators({ feeds: [whatsHot] }), 199 250 ); 200 251 201 - const rows = results 202 - .map( 203 - (r) => 204 - `<tr><td><code>${r.lxm}</code></td><td class="${r.status.toLowerCase()}">${r.status}</td><td>${ 205 - r.detail ?? "" 206 - }</td></tr>`, 207 - ) 208 - .join(""); 252 + // List / graph RPCs 253 + await run("app.bsky.graph.getLists", () => 254 + agent.app.bsky.graph.getLists({ actor: lastDid!, limit: 1 }), 255 + ); 256 + await run("app.bsky.graph.getListBlocks", () => 257 + agent.app.bsky.graph.getListBlocks({ limit: 1 }), 258 + ); 259 + await run("app.bsky.graph.getListMutes", () => 260 + agent.app.bsky.graph.getListMutes({ limit: 1 }), 261 + ); 262 + await run("app.bsky.graph.getListsWithMembership", () => 263 + agent.app.bsky.graph.getListsWithMembership({ actor: lastDid!, limit: 1 }), 264 + ); 209 265 210 - return c.html(`<!doctype html> 211 - <meta charset="utf-8" /> 212 - <title>Results — authViewAll aud-bug repro</title> 213 - <style> 214 - body{font-family:system-ui;max-width:60em;margin:2em auto;padding:0 1em;line-height:1.5} 215 - table{border-collapse:collapse;width:100%} 216 - td,th{border-bottom:1px solid #ccc;padding:.5em;text-align:left;vertical-align:top} 217 - .ok{color:#067d17;font-weight:600} 218 - .fail{color:#b3261e;font-weight:600} 219 - code{background:#eee;padding:.15em .35em;border-radius:3px} 220 - </style> 221 - <h1>RPC results for ${lastDid}</h1> 222 - <p>Scope granted: <code>${SCOPE}</code></p> 223 - <p>Agent proxy header: <code>atproto-proxy: did:web:api.bsky.app#bsky_appview</code></p> 224 - <table> 225 - <thead><tr><th>Lexicon</th><th>Status</th><th>Detail</th></tr></thead> 226 - <tbody>${rows}</tbody> 227 - </table> 228 - <p><a href="/test">Re-run tests</a></p>`); 266 + return c.html( 267 + renderResults({ did: lastDid, scope: SCOPE, granted: grantedTokenInfo, results }), 268 + ); 229 269 }); 230 270 231 271 serve({ fetch: app.fetch, port: PORT }, () => {
+125
src/templates.ts
··· 1 + export interface TestResult { 2 + lxm: string; 3 + status: "OK" | "FAIL"; 4 + detail?: string; 5 + proxyHeader?: string | null; 6 + httpStatus?: number; 7 + errorCode?: string; 8 + errorHeaders?: Record<string, string | undefined>; 9 + } 10 + 11 + export interface GrantedTokenInfo { 12 + scope?: string; 13 + aud?: string; 14 + iss?: string; 15 + } 16 + 17 + const ESCAPE_MAP: Record<string, string> = { 18 + "&": "&amp;", 19 + "<": "&lt;", 20 + ">": "&gt;", 21 + '"': "&quot;", 22 + "'": "&#39;", 23 + }; 24 + 25 + const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]!); 26 + 27 + // Scope strings are space-separated tokens; tokens like `rpc?lxm=a&lxm=b&aud=...` 28 + // pack many query params onto one line. Split by space, then by `&` within each 29 + // token, with continuation lines indented so the structure is obvious. 30 + const formatScope = (scope: string) => 31 + scope 32 + .split(/\s+/) 33 + .filter(Boolean) 34 + .map((token) => { 35 + const qIdx = token.indexOf("?"); 36 + if (qIdx === -1) return token; 37 + const head = token.slice(0, qIdx); 38 + const params = token.slice(qIdx + 1).split("&"); 39 + return `${head}?${params[0]}\n${params 40 + .slice(1) 41 + .map((p) => ` &${p}`) 42 + .join("\n")}`; 43 + }) 44 + .join("\n\n"); 45 + 46 + export const renderHome = (scope: string) => `<!doctype html> 47 + <meta charset="utf-8" /> 48 + <title>authViewAll aud-bug repro</title> 49 + <style>body{font-family:system-ui;max-width:40em;margin:2em auto;padding:0 1em;line-height:1.5}input,button{font-size:1em;padding:.4em}code{background:#eee;padding:.15em .35em;border-radius:3px}</style> 50 + <h1>authViewAll aud enforcement repro</h1> 51 + <p>Scope: <code>${escape(scope)}</code></p> 52 + <form action="/login" method="POST"> 53 + <input name="handle" placeholder="your.bsky.social handle" required /> 54 + <button>Log in with atproto</button> 55 + </form> 56 + <p>After login you will be redirected to <a href="/test">/test</a>, which calls several RPCs and reports results.</p>`; 57 + 58 + export const renderNotLoggedIn = () => 59 + `<p>Not logged in. <a href="/">Log in</a> first.</p>`; 60 + 61 + export const renderResults = (args: { 62 + did: string; 63 + scope: string; 64 + granted: GrantedTokenInfo | null; 65 + results: TestResult[]; 66 + }) => { 67 + const { did, scope, granted, results } = args; 68 + 69 + const rows = results 70 + .map((r) => { 71 + const extras: string[] = []; 72 + if (r.httpStatus !== undefined) extras.push(`HTTP ${r.httpStatus}`); 73 + if (r.errorCode) extras.push(`error=${escape(r.errorCode)}`); 74 + const proxy = 75 + r.proxyHeader === null ? "&lt;unset&gt;" : escape(r.proxyHeader ?? ""); 76 + return `<tr> 77 + <td><code>${escape(r.lxm)}</code></td> 78 + <td class="${r.status.toLowerCase()}">${r.status}</td> 79 + <td><code>${proxy}</code></td> 80 + <td>${r.detail ? escape(r.detail) : ""}${ 81 + extras.length ? `<br /><small>${extras.join(" · ")}</small>` : "" 82 + }</td> 83 + </tr>`; 84 + }) 85 + .join(""); 86 + 87 + const grantedBlock = granted 88 + ? `<dl> 89 + <dt>scope</dt> 90 + <dd><textarea readonly rows="20">${escape(formatScope(granted.scope ?? ""))}</textarea></dd> 91 + <dt>aud</dt> 92 + <dd><code>${escape(granted.aud ?? "")}</code></dd> 93 + <dt>iss</dt> 94 + <dd><code>${escape(granted.iss ?? "")}</code></dd> 95 + </dl>` 96 + : "<p>(no token info captured)</p>"; 97 + 98 + return `<!doctype html> 99 + <meta charset="utf-8" /> 100 + <title>Results — authViewAll aud-bug repro</title> 101 + <style> 102 + body{font-family:system-ui;max-width:72em;margin:2em auto;padding:0 1em;line-height:1.5} 103 + table{border-collapse:collapse;width:100%} 104 + td,th{border-bottom:1px solid #ccc;padding:.5em;text-align:left;vertical-align:top} 105 + .ok{color:#067d17;font-weight:600} 106 + .fail{color:#b3261e;font-weight:600} 107 + code{background:#eee;padding:.15em .35em;border-radius:3px} 108 + small{color:#666} 109 + textarea{width:100%;font-family:ui-monospace,monospace;font-size:.9em;padding:.4em;box-sizing:border-box;white-space:pre-wrap;word-break:break-all} 110 + dl{margin:0}dt{font-weight:600;margin-top:.5em}dd{margin:.2em 0 .5em 0} 111 + </style> 112 + <h1>RPC results for ${escape(did)}</h1> 113 + <h2>Scope requested</h2> 114 + <textarea readonly rows="6">${escape(formatScope(scope))}</textarea> 115 + <h2>Scope / token granted (from AS token response)</h2> 116 + ${grantedBlock} 117 + <h2>Agent proxy</h2> 118 + <p>Configured via <code>.withProxy("bsky_appview", "did:web:api.bsky.app")</code> → header <code>atproto-proxy: did:web:api.bsky.app#bsky_appview</code></p> 119 + <h2>Per-RPC results</h2> 120 + <table> 121 + <thead><tr><th>Lexicon</th><th>Status</th><th>Outgoing atproto-proxy</th><th>Detail</th></tr></thead> 122 + <tbody>${rows}</tbody> 123 + </table> 124 + <p><a href="/test">Re-run tests</a></p>`; 125 + };