nandi/frqpublic Fork 0
8c5b241
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-20T11:59:11-07:00 Browse files
8c5b241 parent: 2d13303
modified flutter/web/frq_oauth.js +31 -10
@@ -182,9 +182,19 @@
182182 // It proves the proof is accepted before one is handed to freeq, since this
183183 // is the very call freeq will make with it. And it collects the nonce the
184184 // 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.
185192 async function whoami(pds, token) {
186193 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+ }
188198 const j = JSON.parse(r.body);
189199 return { handle: j.handle || '', did: j.did || '', nonce: r.nonce || '' };
190200 }
@@ -275,14 +285,27 @@
275285 if (r.status !== 200) throw new Error('Sign-in failed: ' + r.body);
276286
277287 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+
278301 const who = await whoami(pending.pds, t.access_token);
279302 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 || '',
282305 accessJwt: t.access_token,
283306 refresh: t.refresh_token || '',
284307 pds: pending.pds,
285- dpopNonce: (who && who.nonce) || '',
308+ dpopNonce: who.nonce || '',
286309 };
287310 drop(PENDING);
288311 save(SESSION, session);
@@ -313,12 +336,10 @@
313336 const s = saved();
314337 if (!s) throw new Error('not signed in');
315338 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);
322343 s.dpopProof = await dpop().proof(
323344 'GET', sessionUrl(s.pds), s.dpopNonce || '', s.accessJwt);
324345 return s;
@@ -182,9 +182,19 @@
182 // It proves the proof is accepted before one is handed to freeq, since this182 // 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 the183 // 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;