A sign-in the PDS refused is not a sign-in
`whoami` returned null for any non-200 and the caller shrugged: the session was built from whatever the token response happened to say and the connection went ahead. That is the shape of "logged in with atproto but using the guest id", and from the console it is a bare 401 at `com.atproto.server.getSession` with nothing to say why. It throws now, carrying the status and the body, which is the only place the reason was ever written down. And the token response is checked for the scope it granted, which the profile requires and which is the other way this fails quietly: a token narrower than the one asked for works for nothing and only says so later, as that same unexplained 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8c5b241 parent: 2d13303 modified
flutter/web/frq_oauth.js +31 -10 | @@ -182,9 +182,19 @@ | ||
| 182 | 182 | // It proves the proof is accepted before one is handed to freeq, since this |
| 183 | 183 | // is the very call freeq will make with it. And it collects the nonce the |
| 184 | 184 | // PDS wants, so the proof minted at connect carries one already. |
| 185 | + // | |
| 186 | + // It fails loudly, and that is the third thing. A refusal here used to | |
| 187 | + // return null and let the sign-in carry on with whatever the token | |
| 188 | + // response happened to say, which is how a rejected token became a guest | |
| 189 | + // on the server with no word about why -- and, from the outside, an | |
| 190 | + // unexplained 401 in the console. Whoever the PDS will not vouch for is | |
| 191 | + // not signed in. | |
| 185 | 192 | async function whoami(pds, token) { |
| 186 | 193 | const r = await getWithDpop(sessionUrl(pds), token, ''); |
| 187 | - if (r.status !== 200) return null; | |
| 194 | + if (r.status !== 200) { | |
| 195 | + throw new Error('The PDS would not accept the token (' + r.status + | |
| 196 | + '): ' + r.body); | |
| 197 | + } | |
| 188 | 198 | const j = JSON.parse(r.body); |
| 189 | 199 | return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' }; |
| 190 | 200 | } |
| @@ -275,14 +285,27 @@ | ||
| 275 | 285 | if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body); |
| 276 | 286 | |
| 277 | 287 | const t = JSON.parse(r.body); |
| 288 | + | |
| 289 | + // What was granted, which is not always what was asked for. The profile | |
| 290 | + // requires servers to return the granted scopes and clients to reject a | |
| 291 | + // response without `atproto` -- and the failure this catches is the | |
| 292 | + // quiet one: a token granted a narrower scope than requested works for | |
| 293 | + // nothing and says so only much later, as a 401 from the PDS with no | |
| 294 | + // hint that a scope was the reason. | |
| 295 | + const granted = String(t.scope || '').split(/\s+/); | |
| 296 | + if (!granted.includes('atproto')) { | |
| 297 | + throw new Error('Signed in without the atproto scope: ' + | |
| 298 | + (t.scope || 'none granted')); | |
| 299 | + } | |
| 300 | + | |
| 278 | 301 | const who = await whoami(pending.pds, t.access_token); |
| 279 | 302 | const session = { |
| 280 | - did: (who && who.did) || t.sub || pending.did, | |
| 281 | - handle: (who && who.handle) || pending.handle || '', | |
| 303 | + did: who.did || t.sub || pending.did, | |
| 304 | + handle: who.handle || pending.handle || '', | |
| 282 | 305 | accessJwt: t.access_token, |
| 283 | 306 | refresh: t.refresh_token || '', |
| 284 | 307 | pds: pending.pds, |
| 285 | - dpopNonce: (who && who.nonce) || '', | |
| 308 | + dpopNonce: who.nonce || '', | |
| 286 | 309 | }; |
| 287 | 310 | drop(PENDING); |
| 288 | 311 | save(SESSION, session); |
| @@ -313,12 +336,10 @@ | ||
| 313 | 336 | const s = saved(); |
| 314 | 337 | if (!s) throw new Error('not signed in'); |
| 315 | 338 | const who = await whoami(s.pds, s.accessJwt); |
| 316 | - if (who) { | |
| 317 | - if (who.handle) s.handle = who.handle; | |
| 318 | - if (who.did) s.did = who.did; | |
| 319 | - if (who.nonce) s.dpopNonce = who.nonce; | |
| 320 | - save(SESSION, s); | |
| 321 | - } | |
| 339 | + if (who.handle) s.handle = who.handle; | |
| 340 | + if (who.did) s.did = who.did; | |
| 341 | + if (who.nonce) s.dpopNonce = who.nonce; | |
| 342 | + save(SESSION, s); | |
| 322 | 343 | s.dpopProof = await dpop().proof( |
| 323 | 344 | 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt); |
| 324 | 345 | return s; |
| @@ -182,9 +182,19 @@ | |||
| 182 | // It proves the proof is accepted before one is handed to freeq, since this | 182 | // It proves the proof is accepted before one is handed to freeq, since this |
| 183 | // is the very call freeq will make with it. And it collects the nonce the | 183 | // is the very call freeq will make with it. And it collects the nonce the |
| 184 | // PDS wants, so the proof minted at connect carries one already. | 184 | // PDS wants, so the proof minted at connect carries one already. |
| 185 | + // | ||
| 186 | + // It fails loudly, and that is the third thing. A refusal here used to | ||
| 187 | + // return null and let the sign-in carry on with whatever the token | ||
| 188 | + // response happened to say, which is how a rejected token became a guest | ||
| 189 | + // on the server with no word about why -- and, from the outside, an | ||
| 190 | + // unexplained 401 in the console. Whoever the PDS will not vouch for is | ||
| 191 | + // not signed in. | ||
| 185 | async function whoami(pds, token) { | 192 | async function whoami(pds, token) { |
| 186 | const r = await getWithDpop(sessionUrl(pds), token, ''); | 193 | const r = await getWithDpop(sessionUrl(pds), token, ''); |
| 187 | - if (r.status !== 200) return null; | 194 | + if (r.status !== 200) { |
| 195 | + throw new Error('The PDS would not accept the token (' + r.status + | ||
| 196 | + '): ' + r.body); | ||
| 197 | + } | ||
| 188 | const j = JSON.parse(r.body); | 198 | const j = JSON.parse(r.body); |
| 189 | return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' }; | 199 | return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' }; |
| 190 | } | 200 | } |
| @@ -275,14 +285,27 @@ | |||
| 275 | if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body); | 285 | if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body); |
| 276 | 286 | ||
| 277 | const t = JSON.parse(r.body); | 287 | const t = JSON.parse(r.body); |
| 288 | + | ||
| 289 | + // What was granted, which is not always what was asked for. The profile | ||
| 290 | + // requires servers to return the granted scopes and clients to reject a | ||
| 291 | + // response without `atproto` -- and the failure this catches is the | ||
| 292 | + // quiet one: a token granted a narrower scope than requested works for | ||
| 293 | + // nothing and says so only much later, as a 401 from the PDS with no | ||
| 294 | + // hint that a scope was the reason. | ||
| 295 | + const granted = String(t.scope || '').split(/\s+/); | ||
| 296 | + if (!granted.includes('atproto')) { | ||
| 297 | + throw new Error('Signed in without the atproto scope: ' + | ||
| 298 | + (t.scope || 'none granted')); | ||
| 299 | + } | ||
| 300 | + | ||
| 278 | const who = await whoami(pending.pds, t.access_token); | 301 | const who = await whoami(pending.pds, t.access_token); |
| 279 | const session = { | 302 | const session = { |
| 280 | - did: (who && who.did) || t.sub || pending.did, | 303 | + did: who.did || t.sub || pending.did, |
| 281 | - handle: (who && who.handle) || pending.handle || '', | 304 | + handle: who.handle || pending.handle || '', |
| 282 | accessJwt: t.access_token, | 305 | accessJwt: t.access_token, |
| 283 | refresh: t.refresh_token || '', | 306 | refresh: t.refresh_token || '', |
| 284 | pds: pending.pds, | 307 | pds: pending.pds, |
| 285 | - dpopNonce: (who && who.nonce) || '', | 308 | + dpopNonce: who.nonce || '', |
| 286 | }; | 309 | }; |
| 287 | drop(PENDING); | 310 | drop(PENDING); |
| 288 | save(SESSION, session); | 311 | save(SESSION, session); |
| @@ -313,12 +336,10 @@ | |||
| 313 | const s = saved(); | 336 | const s = saved(); |
| 314 | if (!s) throw new Error('not signed in'); | 337 | if (!s) throw new Error('not signed in'); |
| 315 | const who = await whoami(s.pds, s.accessJwt); | 338 | const who = await whoami(s.pds, s.accessJwt); |
| 316 | - if (who) { | 339 | + if (who.handle) s.handle = who.handle; |
| 317 | - if (who.handle) s.handle = who.handle; | 340 | + if (who.did) s.did = who.did; |
| 318 | - if (who.did) s.did = who.did; | 341 | + if (who.nonce) s.dpopNonce = who.nonce; |
| 319 | - if (who.nonce) s.dpopNonce = who.nonce; | 342 | + save(SESSION, s); |
| 320 | - save(SESSION, s); | ||
| 321 | - } | ||
| 322 | s.dpopProof = await dpop().proof( | 343 | s.dpopProof = await dpop().proof( |
| 323 | 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt); | 344 | 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt); |
| 324 | return s; | 345 | return s; |