nandi/frqpublic Fork 0
b8610a1
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.

Spending the refresh token, which nothing ever did

`resume` stored a `refresh_token` and no code path reached for it. An
access token is good for minutes -- the profile says under thirty and
recommends five -- and the app stays open for longer, so a reader who
signed in and came back held a session that looked complete and a PDS
that refused it. That is the 401 at `com.atproto.server.getSession`
from `prepare`, and before the last commit it was also the silent slide
into a guest id while signed in.

`prepare` now treats a refusal as what it nearly always is: an expired
token. Spend the refresh, ask again. The replacement is saved before
anything else can fail, because refresh tokens are single-use and one
lost in flight is a session that cannot be recovered.

When the refresh will not trade either, the session is cleared rather
than kept. "Signed in, and nothing works" is a worse place to leave
somebody than a sign-in button.

`nim/web/test/session.js` covers it offline with a stub PDS, and joins
`just test web`. Reverting the retry reproduces the reported error
verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T12:39:56-07:00 Browse files
b8610a1 parent: 7b4d21d
modified flutter/web/frq_oauth.js +53 -3
@@ -305,6 +305,10 @@
305305 accessJwt: t.access_token,
306306 refresh: t.refresh_token || '',
307307 pds: pending.pds,
308+ // Where a refresh goes. Discovery would find it again, but it is two
309+ // round trips to learn something already known, on the path taken
310+ // every time the app opens.
311+ token: pending.token,
308312 dpopNonce: who.nonce || '',
309313 };
310314 drop(PENDING);
@@ -322,6 +326,42 @@
322326 dpop().forget();
323327 }
324328
329+ // Trade the refresh token for a fresh access token.
330+ //
331+ // This was the missing half of the session: `resume` stored a
332+ // `refresh_token` and nothing ever spent it. An access token is good for
333+ // minutes -- the profile says under thirty and recommends five -- so a
334+ // reader who signed in and came back an hour later had a session that
335+ // looked complete, a PDS that refused it, and a client that shrugged and
336+ // connected as a guest.
337+ //
338+ // The new refresh token replaces the old one and the old one is spent:
339+ // they are single-use, so it is saved before anything else can fail.
340+ async function refresh() {
341+ const s = saved();
342+ if (!s || !s.refresh) throw new Error('not signed in');
343+ const where = s.token || (await discover(s.pds)).token;
344+ const r = await postForm(where, form([
345+ ['grant_type', 'refresh_token'],
346+ ['refresh_token', s.refresh],
347+ ['client_id', clientId()],
348+ ]), null);
349+ if (r.status !== 200) {
350+ // The refresh token is gone or was revoked, and no retry will bring it
351+ // back. Clearing the session is what turns "signed in, and nothing
352+ // works" into a sign-in button.
353+ forget();
354+ throw new Error('The session has expired; sign in again (' +
355+ r.status + '): ' + r.body);
356+ }
357+ const t = JSON.parse(r.body);
358+ s.accessJwt = t.access_token;
359+ if (t.refresh_token) s.refresh = t.refresh_token;
360+ s.token = where;
361+ save(SESSION, s);
362+ return s;
363+ }
364+
325365 // Mint the proof freeq will present to the PDS on our behalf.
326366 //
327367 // For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the
@@ -333,9 +373,19 @@
333373 // access token lives about an hour, and the only thing that comes back
334374 // through IRC when it has expired is a bare failure.
335375 async function prepare() {
336- const s = saved();
376+ let s = saved();
337377 if (!s) throw new Error('not signed in');
338- const who = await whoami(s.pds, s.accessJwt);
378+ let who;
379+ try {
380+ who = await whoami(s.pds, s.accessJwt);
381+ } catch (e) {
382+ // Asking who the token belongs to is also how its age is discovered.
383+ // A refusal here is nearly always an expired access token, so spend
384+ // the refresh token and ask once more; if that fails it throws, and
385+ // saying so beats connecting as somebody else.
386+ s = await refresh();
387+ who = await whoami(s.pds, s.accessJwt);
388+ }
339389 if (who.handle) s.handle = who.handle;
340390 if (who.did) s.did = who.did;
341391 if (who.nonce) s.dpopNonce = who.nonce;
@@ -345,5 +395,5 @@
345395 return s;
346396 }
347397
348- window.frqOauth = { begin, resume, saved, forget, prepare };
398+ window.frqOauth = { begin, resume, saved, forget, prepare, refresh };
349399 })();
@@ -305,6 +305,10 @@
305 accessJwt: t.access_token,305 accessJwt: t.access_token,
306 refresh: t.refresh_token || '',306 refresh: t.refresh_token || '',
307 pds: pending.pds,307 pds: pending.pds,
308+ // Where a refresh goes. Discovery would find it again, but it is two
309+ // round trips to learn something already known, on the path taken
310+ // every time the app opens.
311+ token: pending.token,
308 dpopNonce: who.nonce || '',312 dpopNonce: who.nonce || '',
309 };313 };
310 drop(PENDING);314 drop(PENDING);
@@ -322,6 +326,42 @@
322 dpop().forget();326 dpop().forget();
323 }327 }
324 328
329+ // Trade the refresh token for a fresh access token.
330+ //
331+ // This was the missing half of the session: `resume` stored a
332+ // `refresh_token` and nothing ever spent it. An access token is good for
333+ // minutes -- the profile says under thirty and recommends five -- so a
334+ // reader who signed in and came back an hour later had a session that
335+ // looked complete, a PDS that refused it, and a client that shrugged and
336+ // connected as a guest.
337+ //
338+ // The new refresh token replaces the old one and the old one is spent:
339+ // they are single-use, so it is saved before anything else can fail.
340+ async function refresh() {
341+ const s = saved();
342+ if (!s || !s.refresh) throw new Error('not signed in');
343+ const where = s.token || (await discover(s.pds)).token;
344+ const r = await postForm(where, form([
345+ ['grant_type', 'refresh_token'],
346+ ['refresh_token', s.refresh],
347+ ['client_id', clientId()],
348+ ]), null);
349+ if (r.status !== 200) {
350+ // The refresh token is gone or was revoked, and no retry will bring it
351+ // back. Clearing the session is what turns "signed in, and nothing
352+ // works" into a sign-in button.
353+ forget();
354+ throw new Error('The session has expired; sign in again (' +
355+ r.status + '): ' + r.body);
356+ }
357+ const t = JSON.parse(r.body);
358+ s.accessJwt = t.access_token;
359+ if (t.refresh_token) s.refresh = t.refresh_token;
360+ s.token = where;
361+ save(SESSION, s);
362+ return s;
363+ }
364+
325 // Mint the proof freeq will present to the PDS on our behalf.365 // Mint the proof freeq will present to the PDS on our behalf.
326 //366 //
327 // For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the367 // For `GET {pds}/xrpc/com.atproto.server.getSession` and bound to the
@@ -333,9 +373,19 @@
333 // access token lives about an hour, and the only thing that comes back373 // access token lives about an hour, and the only thing that comes back
334 // through IRC when it has expired is a bare failure.374 // through IRC when it has expired is a bare failure.
335 async function prepare() {375 async function prepare() {
336- const s = saved();376+ let s = saved();
337 if (!s) throw new Error('not signed in');377 if (!s) throw new Error('not signed in');
338- const who = await whoami(s.pds, s.accessJwt);378+ let who;
379+ try {
380+ who = await whoami(s.pds, s.accessJwt);
381+ } catch (e) {
382+ // Asking who the token belongs to is also how its age is discovered.
383+ // A refusal here is nearly always an expired access token, so spend
384+ // the refresh token and ask once more; if that fails it throws, and
385+ // saying so beats connecting as somebody else.
386+ s = await refresh();
387+ who = await whoami(s.pds, s.accessJwt);
388+ }
339 if (who.handle) s.handle = who.handle;389 if (who.handle) s.handle = who.handle;
340 if (who.did) s.did = who.did;390 if (who.did) s.did = who.did;
341 if (who.nonce) s.dpopNonce = who.nonce;391 if (who.nonce) s.dpopNonce = who.nonce;
@@ -345,5 +395,5 @@
345 return s;395 return s;
346 }396 }
347 397
348- window.frqOauth = { begin, resume, saved, forget, prepare };398+ window.frqOauth = { begin, resume, saved, forget, prepare, refresh };
349 })();399 })();
modified justfile +2 -1
@@ -109,7 +109,8 @@ test suite="all" *args:
109109 just _flutter layout test ;;
110110 nim) just _nim-test "$@" ;;
111111 web) just _nim-js
112- exec node nim/web/test/smoke.js build/web/frq_core.js ;;
112+ node nim/web/test/smoke.js build/web/frq_core.js
113+ exec node nim/web/test/session.js ;;
113114 dart) just _nim-lib
114115 exec "{{tc}}" exec -- bash -c \
115116 'cd dart/frq_core && dart pub get && dart test -r expanded' ;;
@@ -109,7 +109,8 @@ test suite="all" *args:
109 just _flutter layout test ;;109 just _flutter layout test ;;
110 nim) just _nim-test "$@" ;;110 nim) just _nim-test "$@" ;;
111 web) just _nim-js111 web) just _nim-js
112- exec node nim/web/test/smoke.js build/web/frq_core.js ;;112+ node nim/web/test/smoke.js build/web/frq_core.js
113+ exec node nim/web/test/session.js ;;
113 dart) just _nim-lib114 dart) just _nim-lib
114 exec "{{tc}}" exec -- bash -c \115 exec "{{tc}}" exec -- bash -c \
115 'cd dart/frq_core && dart pub get && dart test -r expanded' ;;116 'cd dart/frq_core && dart pub get && dart test -r expanded' ;;
added nim/web/test/session.js +112 -0
new file mode 100644
@@ -0,0 +1,112 @@
1+// The sign-in that outlives its access token.
2+//
3+// node nim/web/test/session.js
4+//
5+// Offline: `fetch` is a stub here and no network is touched. What it covers
6+// is the half of the session that had no caller -- `resume` stored a
7+// `refresh_token` and nothing ever spent it, so a reader who came back an
8+// hour later held a session that looked complete and a PDS that refused it.
9+// An access token is good for minutes; the app is open for longer.
10+const fs = require('fs');
11+const assert = require('assert');
12+
13+let store = {};
14+global.localStorage = {
15+ getItem(k) { return k in store ? store[k] : null; },
16+ setItem(k, v) { store[k] = String(v); },
17+ removeItem(k) { delete store[k]; },
18+};
19+global.window = {
20+ location: { origin: 'http://127.0.0.1:8000', hostname: '127.0.0.1',
21+ pathname: '/', search: '', assign() {} },
22+ history: { replaceState() {} },
23+};
24+global.crypto = require('crypto').webcrypto;
25+global.btoa = (s) => Buffer.from(s, 'binary').toString('base64');
26+global.TextEncoder = require('util').TextEncoder;
27+global.URLSearchParams = URLSearchParams;
28+
29+// The PDS and the authorization server, as far as this cares: an access
30+// token is accepted only while it is the current one.
31+let live = 'fresh-token';
32+let refreshes = 0;
33+let refreshValid = true;
34+const calls = [];
35+global.fetch = async (url, opts) => {
36+ const body = String((opts && opts.body) || '');
37+ calls.push(String(url));
38+ const reply = (status, obj, headers) => ({
39+ status: status,
40+ text: async () => JSON.stringify(obj),
41+ headers: { get: (h) => (headers || {})[h.toLowerCase()] || null },
42+ });
43+ if (String(url).endsWith('/xrpc/com.atproto.server.getSession')) {
44+ const bearer = (opts.headers.Authorization || '').replace('DPoP ', '');
45+ if (bearer !== live) return reply(401, { error: 'InvalidToken' });
46+ return reply(200, { did: 'did:plc:abc', handle: 'someone.example' },
47+ { 'dpop-nonce': 'n1' });
48+ }
49+ if (String(url).endsWith('/token')) {
50+ assert.ok(body.includes('grant_type=refresh_token'), 'a refresh grant');
51+ assert.ok(body.includes('refresh_token=r1'), 'spends the stored token');
52+ if (!refreshValid) return reply(400, { error: 'invalid_grant' });
53+ refreshes += 1;
54+ live = 'second-token';
55+ return reply(200, { access_token: live, refresh_token: 'r2',
56+ scope: 'atproto transition:generic' });
57+ }
58+ throw new Error('unexpected request: ' + url);
59+};
60+
61+(0, eval)(fs.readFileSync('flutter/web/frq_dpop.js', 'utf8'));
62+(0, eval)(fs.readFileSync('flutter/web/frq_oauth.js', 'utf8'));
63+
64+const ok = (name) => console.log(' ok ' + name);
65+const session = (over) => Object.assign({
66+ did: 'did:plc:abc', handle: 'someone.example', accessJwt: 'fresh-token',
67+ refresh: 'r1', pds: 'https://pds.example',
68+ token: 'https://pds.example/token', dpopNonce: '',
69+}, over || {});
70+
71+(async () => {
72+ // A token the PDS still accepts is used as it is.
73+ store = {}; live = 'fresh-token'; refreshes = 0;
74+ localStorage.setItem('frq:oauth:session', JSON.stringify(session()));
75+ let s = await window.frqOauth.prepare();
76+ assert.equal(refreshes, 0);
77+ assert.ok(s.dpopProof, 'a proof for freeq to present');
78+ ok('a live token is not refreshed');
79+
80+ // The nonce the PDS asked for is kept, so the proof minted at connect
81+ // carries one already.
82+ assert.equal(window.frqOauth.saved().dpopNonce, 'n1');
83+ ok('and the nonce it answered with is kept');
84+
85+ // The case that was silently broken.
86+ store = {}; live = 'fresh-token'; refreshes = 0; refreshValid = true;
87+ localStorage.setItem('frq:oauth:session',
88+ JSON.stringify(session({ accessJwt: 'stale' })));
89+ s = await window.frqOauth.prepare();
90+ assert.equal(refreshes, 1, 'the refresh token was spent');
91+ assert.equal(s.accessJwt, 'second-token');
92+ assert.equal(s.handle, 'someone.example', 'still knows who it is');
93+ ok('a token the PDS refuses is refreshed, once');
94+
95+ // Single-use: the replacement is stored, or the next refresh spends a
96+ // token that is already gone.
97+ assert.equal(window.frqOauth.saved().refresh, 'r2');
98+ ok('and the new refresh token replaces the spent one');
99+
100+ // No refresh left. Connecting as a guest here is the bug this is named
101+ // after; the session goes and the reader is asked to sign in.
102+ store = {}; live = 'fresh-token'; refreshValid = false;
103+ localStorage.setItem('frq:oauth:session',
104+ JSON.stringify(session({ accessJwt: 'stale' })));
105+ let threw = '';
106+ try { await window.frqOauth.prepare(); } catch (e) { threw = String(e); }
107+ assert.ok(/expired/.test(threw), 'says so: ' + threw);
108+ assert.equal(window.frqOauth.saved(), null, 'and the session is cleared');
109+ ok('a refusal that no refresh fixes ends the session, loudly');
110+
111+ console.log('all ok');
112+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
@@ -0,0 +1,112 @@
1+// The sign-in that outlives its access token.
2+//
3+// node nim/web/test/session.js
4+//
5+// Offline: `fetch` is a stub here and no network is touched. What it covers
6+// is the half of the session that had no caller -- `resume` stored a
7+// `refresh_token` and nothing ever spent it, so a reader who came back an
8+// hour later held a session that looked complete and a PDS that refused it.
9+// An access token is good for minutes; the app is open for longer.
10+const fs = require('fs');
11+const assert = require('assert');
12+
13+let store = {};
14+global.localStorage = {
15+ getItem(k) { return k in store ? store[k] : null; },
16+ setItem(k, v) { store[k] = String(v); },
17+ removeItem(k) { delete store[k]; },
18+};
19+global.window = {
20+ location: { origin: 'http://127.0.0.1:8000', hostname: '127.0.0.1',
21+ pathname: '/', search: '', assign() {} },
22+ history: { replaceState() {} },
23+};
24+global.crypto = require('crypto').webcrypto;
25+global.btoa = (s) => Buffer.from(s, 'binary').toString('base64');
26+global.TextEncoder = require('util').TextEncoder;
27+global.URLSearchParams = URLSearchParams;
28+
29+// The PDS and the authorization server, as far as this cares: an access
30+// token is accepted only while it is the current one.
31+let live = 'fresh-token';
32+let refreshes = 0;
33+let refreshValid = true;
34+const calls = [];
35+global.fetch = async (url, opts) => {
36+ const body = String((opts && opts.body) || '');
37+ calls.push(String(url));
38+ const reply = (status, obj, headers) => ({
39+ status: status,
40+ text: async () => JSON.stringify(obj),
41+ headers: { get: (h) => (headers || {})[h.toLowerCase()] || null },
42+ });
43+ if (String(url).endsWith('/xrpc/com.atproto.server.getSession')) {
44+ const bearer = (opts.headers.Authorization || '').replace('DPoP ', '');
45+ if (bearer !== live) return reply(401, { error: 'InvalidToken' });
46+ return reply(200, { did: 'did:plc:abc', handle: 'someone.example' },
47+ { 'dpop-nonce': 'n1' });
48+ }
49+ if (String(url).endsWith('/token')) {
50+ assert.ok(body.includes('grant_type=refresh_token'), 'a refresh grant');
51+ assert.ok(body.includes('refresh_token=r1'), 'spends the stored token');
52+ if (!refreshValid) return reply(400, { error: 'invalid_grant' });
53+ refreshes += 1;
54+ live = 'second-token';
55+ return reply(200, { access_token: live, refresh_token: 'r2',
56+ scope: 'atproto transition:generic' });
57+ }
58+ throw new Error('unexpected request: ' + url);
59+};
60+
61+(0, eval)(fs.readFileSync('flutter/web/frq_dpop.js', 'utf8'));
62+(0, eval)(fs.readFileSync('flutter/web/frq_oauth.js', 'utf8'));
63+
64+const ok = (name) => console.log(' ok ' + name);
65+const session = (over) => Object.assign({
66+ did: 'did:plc:abc', handle: 'someone.example', accessJwt: 'fresh-token',
67+ refresh: 'r1', pds: 'https://pds.example',
68+ token: 'https://pds.example/token', dpopNonce: '',
69+}, over || {});
70+
71+(async () => {
72+ // A token the PDS still accepts is used as it is.
73+ store = {}; live = 'fresh-token'; refreshes = 0;
74+ localStorage.setItem('frq:oauth:session', JSON.stringify(session()));
75+ let s = await window.frqOauth.prepare();
76+ assert.equal(refreshes, 0);
77+ assert.ok(s.dpopProof, 'a proof for freeq to present');
78+ ok('a live token is not refreshed');
79+
80+ // The nonce the PDS asked for is kept, so the proof minted at connect
81+ // carries one already.
82+ assert.equal(window.frqOauth.saved().dpopNonce, 'n1');
83+ ok('and the nonce it answered with is kept');
84+
85+ // The case that was silently broken.
86+ store = {}; live = 'fresh-token'; refreshes = 0; refreshValid = true;
87+ localStorage.setItem('frq:oauth:session',
88+ JSON.stringify(session({ accessJwt: 'stale' })));
89+ s = await window.frqOauth.prepare();
90+ assert.equal(refreshes, 1, 'the refresh token was spent');
91+ assert.equal(s.accessJwt, 'second-token');
92+ assert.equal(s.handle, 'someone.example', 'still knows who it is');
93+ ok('a token the PDS refuses is refreshed, once');
94+
95+ // Single-use: the replacement is stored, or the next refresh spends a
96+ // token that is already gone.
97+ assert.equal(window.frqOauth.saved().refresh, 'r2');
98+ ok('and the new refresh token replaces the spent one');
99+
100+ // No refresh left. Connecting as a guest here is the bug this is named
101+ // after; the session goes and the reader is asked to sign in.
102+ store = {}; live = 'fresh-token'; refreshValid = false;
103+ localStorage.setItem('frq:oauth:session',
104+ JSON.stringify(session({ accessJwt: 'stale' })));
105+ let threw = '';
106+ try { await window.frqOauth.prepare(); } catch (e) { threw = String(e); }
107+ assert.ok(/expired/.test(threw), 'says so: ' + threw);
108+ assert.equal(window.frqOauth.saved(), null, 'and the session is cleared');
109+ ok('a refusal that no refresh fixes ends the session, loudly');
110+
111+ console.log('all ok');
112+})().catch((e) => { console.error(e); process.exit(1); });