From d6b6daa486936d547d9469497af65ae526fdce3c Mon Sep 17 00:00:00 2001 From: iacore Date: Mon, 3 Aug 2026 11:55:43 +0800 Subject: [PATCH] chore: remove published blog drafts --- post/000.dj | 132 ---------------------------------------------------- post/001.dj | 52 --------------------- 2 files changed, 184 deletions(-) delete mode 100644 post/000.dj delete mode 100644 post/001.dj diff --git a/post/000.dj b/post/000.dj deleted file mode 100644 index 94a0349..0000000 --- a/post/000.dj +++ /dev/null @@ -1,132 +0,0 @@ -# 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 deleted file mode 100644 index acd7b9d..0000000 --- a/post/001.dj +++ /dev/null @@ -1,52 +0,0 @@ -# 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. -- 2.51.2