diff --git a/log/s020.dj b/log/s020.dj index 8ef0c4b..a37d384 100644 --- a/log/s020.dj +++ b/log/s020.dj @@ -83,6 +83,27 @@ seeded with prod clientSecretHex; /etc/hosts not writable - used Still pending: the full callback leg with a real bsky account; user's first live retry via the tongxi invite link. +## Addendum 2: rpc-scope dead end + transition:generic (2026-08-02) + +First real retry failed at bootstrap: bsky's getServiceAuth 403 +ScopeMissingError - the token bsky minted carried only scope="atproto" +even after we requested (and declared in metadata) the spec-correct +`rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at`. bsky's +AS (entryway, closed) accepts the scope at PAR, never grants it, and its +own PDS then demands it (permissions.assertRpc). Evidence + analysis in +docs/post/000.dj. + +Fix: request `atproto transition:generic`. bsky grants it, and +ScopePermissionsTransition.allowsRpc treats it as covering rpc permission +for any non-chat lxm (OAuth creds skip the PRIVILEGED_METHODS check - +type 'oauth'). Verified end to end 10:03Z: login redirect -> callback ok +-> service-auth ok -> createAccount ok -> zds login ok -> bootstrap ok. +User account live on zds with full scope, homePds=yellowfoot.us-west. +host.bsky.network. S020 complete. + +zds keeps request-path debug logging (ZDS_LOG_LEVEL=debug, moved from a +fly secret to fly.toml [env]) - it diagnosed the 424 bearer-auth spam. + ## Follow-ups - Invite code for bootstrap is a shared 500-use code in a fly secret; diff --git a/post/000.dj b/post/000.dj new file mode 100644 index 0000000..94a0349 --- /dev/null +++ b/post/000.dj @@ -0,0 +1,132 @@ +# bsky.social silently drops `rpc:` scopes from access tokens — then 403s `getServiceAuth` + +Date: 2026-08-02 +Status: resolved (workaround deployed); root cause in bsky's AS, closed source + +## TL;DR + +When an OAuth client requests the spec-correct scope for `getServiceAuth` +(`rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at`, declared +in the client metadata, accepted at PAR), bsky.social's authorization +server grants an access token whose `scope` claim contains **only +`atproto`**. The rpc scope is silently dropped — no PAR error, no consent +notice. The PDS's `getServiceAuth` handler then refuses to mint the +service JWT: + +```json +403 {"error":"ScopeMissingError","message":"Missing required scope + \"rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at\""} +``` + +The AS accepts the scope it will never grant, and the resource server +demands the scope it was never given. A client following the OAuth spec +end to end cannot complete this flow against bsky.social. + +## Context + +We run an invite-only community app (linji.at) on our own PDS +(`did:web:zds.linji.at`). External users sign in with any atproto handle, +including bsky.social ones. To give a bsky-hosted user working records on +our PDS we call `com.atproto.server.getServiceAuth` on their home PDS +(address `aud` = our PDS, `lxm` = `com.atproto.server.createAccount`), +then use the service JWT to adopt the DID. That is the standard +[service-auth pattern](https://atproto.com/guides/oauth-patterns). + +## Exact reproduction (2026-08-02) + +1. Client metadata (`https://app0.linji.at/client-metadata.json`) + declares the maximum scope set, including the rpc permission: + + ``` + scope: atproto repo:* space:… rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at + ``` + +2. PAR against `https://bsky.social/oauth/par` with + `scope=atproto rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at` + and `login_hint=1a-insec.net` → `200 {"request_uri":"urn:…"}`. + No error, no scope warning. + +3. User consents at bsky.social's authorize page → authorization code → + token exchange. + +4. The minted access token decodes to: + + ```json + { + "jti": "tok-5eaa2950388fd8d926753021f4eea992", + "sub": "did:plc:73xkqje76lj5tostbsqa74b7", + "iat": 1785663499, + "exp": 1785667099, + "aud": "did:web:yellowfoot.us-west.host.bsky.network", + "cnf": { "jkt": "rzAx5T-2KMzuvDPqx5Sg5WKMCXW7kW9tUvtfktHuXM4" }, + "scope": "atproto", + "client_id": "https://app0.linji.at/client-metadata.json", + "iss": "https://bsky.social" + } + ``` + + `scope` is `"atproto"` — the requested rpc scope is absent. + +5. `GET /xrpc/com.atproto.server.getServiceAuth?aud=did:web:zds.linji.at&lxm=com.atproto.server.createAccount` + with that token (DPoP) → + + ```json + 403 {"error":"ScopeMissingError","message":"Missing required scope + \"rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at\""} + ``` + +Reproduced twice (04:41Z and 09:38Z). The second run requested the exact +scope named in the first run's error; the token still came back with +`atproto` only. + +## The contradiction + +- The AS **accepts** the rpc scope at PAR (step 2) — it is not rejected + as unknown or undeclared. +- The AS **does not grant** it in the token (step 4) — dropped without + any signal to the client or, as far as we can tell, the user. +- The resource server **requires** it (step 5) and errors on the token + the AS just issued. + +The failure is only visible at the last hop, as a 403 whose required +scope the client already requested. Nothing in the OAuth flow tells the +client the grant was truncated. + +## Where we landed + +Root cause: bsky's hosted AS (entryway, closed source) does not grant +aud-specific rpc scopes to third-party clients. It accepts them at PAR, +but the minted token carries only `atproto` - no error, no consent-time +signal. The PDS then demands the scope it never issued +(`ScopeMissingError`, thrown by `permissions.assertRpc` in +auth-verifier.ts). RFC 6749 §3.3 means the token response must carry the +granted scope, so a client checking it could have detected the +shortfall - we (like most clients) didn't. + +Fix (deployed, verified): request the registry migration set instead - +`atproto transition:generic`. bsky grants it, and the PDS's +`ScopePermissionsTransition.allowsRpc` treats it as covering rpc +permission for any non-chat lxm, which is all the bootstrap needs +(`com.atproto.server.createAccount`; OAuth credentials skip the +PRIVILEGED_METHODS check since their type is `oauth`, not `access`). + +Fault verdict: primarily bsky's AS (silent partial grant + its own PDS +requiring what the AS won't issue); shared with the client for not +checking the granted scope, and with the docs for never steering +third parties to `transition:generic`. + +Takeaway for client devs: check the granted scope on every token +response, and if you need `getServiceAuth` against bsky.social, request +`transition:generic`, not an aud-scoped `rpc:` permission. + +## Appendix: environment + +- Client: our BFF (server-mode OAuth, `private_key_jwt`, PAR + PKCE + + DPoP), client metadata at `https://app0.linji.at/client-metadata.json`. +- Handle under test: `1a-insec.net` = `did:plc:73xkqje76lj5tostbsqa74b7` + (custom-domain handle, DNS-TXT-only resolution). +- Home PDS (per DID doc): `https://yellowfoot.us-west.host.bsky.network`; + AS issuer: `https://bsky.social`. +- Spec reference: [atproto OAuth](https://atproto.com/specs/oauth), + [permission sets](https://atproto.com/guides/permission-sets), + [service auth pattern](https://atproto.com/guides/oauth-patterns). diff --git a/post/001.dj b/post/001.dj new file mode 100644 index 0000000..acd7b9d --- /dev/null +++ b/post/001.dj @@ -0,0 +1,52 @@ +# bsky.social OAuth drops your requested scopes, then 403s you for lacking them + +Bluesky post series draft (2026-08-02), 6 posts, each under 300 chars. +Full evidence: docs/post/000.dj. + +## 1 + +bsky.social's OAuth silently drops requested scopes from access tokens, +then its own PDS 403s the very call that needs them. We hit this adding +external sign-in to our app. Here's the exact chain, the evidence, and +who we think is at fault. + +## 2 + +Our app lets any atproto handle sign in. For bsky-hosted users we call +com.atproto.server.getServiceAuth on their home PDS (aud = our PDS, +lxm = com.atproto.server.createAccount) to adopt the DID. Per the spec we +request scope rpc:com.atproto.server.createAccount?aud=did:web:zds.linji.at, +declared in our client metadata. + +## 3 + +What actually happened: +- PAR: accepted, request_uri returned +- consent at bsky.social: completed +- access token: scope claim is just "atproto" - the rpc scope is gone, + no error anywhere +- getServiceAuth with that token: 403 ScopeMissingError, naming exactly + the scope we requested + +## 4 + +The contradiction: the AS accepts a scope at PAR it never grants, the +user is never told, and the resource server then demands it. Decoded +token: jti tok-5eaa2950..., scope "atproto", client_id +app0.linji.at, iss bsky.social. Reproduced twice. + +## 5 + +Who's at fault? Mostly bsky's AS: a partial grant with zero signal, and +its own PDS requiring a scope its AS won't issue. But RFC 6749 s3.3 says +the token response must include the granted scope - bsky did. A client +checking it would have noticed. And nothing steers third parties to the +scope that actually works. + +## 6 + +The fix: request "atproto transition:generic". bsky grants it, and its +PDS treats it as covering rpc permission for any non-chat method +(ScopePermissionsTransition.allowsRpc), which is all we need. +Takeaway: check the granted scope on every token response, and for +getServiceAuth against bsky, use transition:generic.