Skip to content

Commit 8920b53

Browse files
Merge pull request #20 from CodeNeedsCoffee/fix-cross-tab-session-refresh-race
fix cross-tab session refresh race condition
2 parents 8f1fe55 + f2eeae9 commit 8920b53

7 files changed

Lines changed: 237 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## v1.11.2
4+
5+
- **Fixed a cross-tab logout.** Two tabs of the same session left open
6+
long enough could each try to renew an expired login at the same moment;
7+
the server treats that second, near-simultaneous renewal as stolen
8+
credentials and revokes the whole session, logging out every tab even
9+
though nothing was actually wrong. Renewals across tabs are now
10+
serialized so this can no longer happen.
11+
312
## v1.11.1
413

514
- **Core counts you can actually read.** Legacy Cores were the one number in

client/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/game/api.js

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,31 +67,72 @@ export function __resetAuthRefreshForTests() {
6767
// forced logout, and only ever under concurrency.
6868
//
6969
// One promise, shared by every caller, same shape as server/userLock.js.
70+
//
71+
// That covers concurrency WITHIN one tab. It does nothing across tabs, and
72+
// this is an idle game - the genre where players routinely leave it open in
73+
// two or more at once, each polling independently. When two tabs' access
74+
// tokens expire in the same window, each tab's inFlightRefresh is its own
75+
// module-level variable, so each fires its own refresh presenting what was,
76+
// at send time, the SAME refresh-token cookie - the exact multi-caller race
77+
// described above, just with the callers in different browsing contexts
78+
// instead of different promises in one. withRefreshLock is what closes that
79+
// gap: it serialises the actual network call across tabs too.
7080
let inFlightRefresh = null;
7181

82+
// Web Locks name for cross-tab refresh serialisation. Same-origin only by
83+
// construction (the API is scoped per-origin), which is exactly the set of
84+
// tabs sharing the session cookie this is protecting.
85+
const REFRESH_LOCK_NAME = 'rackstack:session-refresh';
86+
87+
/**
88+
* Runs `fn` (the actual refresh network call) exclusively across every
89+
* same-origin tab via the Web Locks API, falling back to running it directly
90+
* when that API is unavailable - older browsers, and this project's own test
91+
* environment, which runs under plain Node and has no navigator.locks (same
92+
* feature-detection convention as makeActionQueue()'s window/navigator checks
93+
* below, and game/auth.js's sessionStorage try/catch: degrade silently to
94+
* today's per-tab-only behaviour rather than throw).
95+
*
96+
* A hand-rolled localStorage/BroadcastChannel mutex could do the same job but
97+
* would need its own deadlock recovery for a tab that dies mid-refresh; Web
98+
* Locks releases automatically when the holding context is destroyed, so
99+
* there is nothing to recover from.
100+
*/
101+
function withRefreshLock(fn) {
102+
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
103+
if (!locks || typeof locks.request !== 'function') return fn();
104+
return locks.request(REFRESH_LOCK_NAME, fn);
105+
}
106+
107+
async function doRefreshRequest() {
108+
try {
109+
const res = await fetch('/auth/session/refresh', {
110+
method: 'POST',
111+
credentials: 'include',
112+
// Same reason as the signinup call in game/auth.js: this is what tells
113+
// SuperTokens to put the rotated tokens back in cookies. Refresh
114+
// tolerates its absence better than session creation does (it infers
115+
// the method from the tokens it was given), but a refresh that
116+
// silently switched the session to header transport would log the
117+
// player out on the next request, which is the same invisible failure
118+
// one step later.
119+
headers: { 'st-auth-mode': 'cookie' },
120+
});
121+
return res.ok;
122+
} catch {
123+
return false;
124+
}
125+
}
126+
72127
function refreshSession() {
73128
if (inFlightRefresh) return inFlightRefresh;
74129

75130
inFlightRefresh = (async () => {
76131
try {
77-
const res = await fetch('/auth/session/refresh', {
78-
method: 'POST',
79-
credentials: 'include',
80-
// Same reason as the signinup call in game/auth.js: this is what tells
81-
// SuperTokens to put the rotated tokens back in cookies. Refresh
82-
// tolerates its absence better than session creation does (it infers
83-
// the method from the tokens it was given), but a refresh that
84-
// silently switched the session to header transport would log the
85-
// player out on the next request, which is the same invisible failure
86-
// one step later.
87-
headers: { 'st-auth-mode': 'cookie' },
88-
});
89-
return res.ok;
90-
} catch {
91-
return false;
132+
return await withRefreshLock(doRefreshRequest);
92133
} finally {
93-
// Cleared before this promise settles, so the NEXT 401 starts a fresh
94-
// attempt rather than re-awaiting a completed one.
134+
// Cleared before this promise settles, so the NEXT 401 in THIS tab
135+
// starts a fresh attempt rather than re-awaiting a completed one.
95136
inFlightRefresh = null;
96137
}
97138
})();

docs/authentication-methods.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ serialised so a burst of concurrent 401s produces exactly one refresh call.
233233
`GET /api/auth-info` tells the client which stack to drive, so one build serves
234234
`passport` and `supertokens` alike and the rollback stays real.
235235

236+
> **That serialisation now spans tabs, not just one page (v1.11.2).** SuperTokens
237+
> rotates the refresh token on every use, so two tabs of the same session
238+
> refreshing at once — routine for an idle game left open in several — each
239+
> present what was, at send time, the same token; whichever the core sees
240+
> second reads as theft and revokes the whole session, logging out every tab.
241+
> `refreshSession()` wraps its network call in a Web Locks
242+
> (`navigator.locks`) mutex shared by every same-origin tab, so two tabs'
243+
> refreshes are never sent concurrently. Falls back to the old per-tab-only
244+
> guarantee on a browser (or test runtime) without Web Locks.
245+
236246
> **If you touch those calls, keep the `st-auth-mode: cookie` header.**
237247
> SuperTokens chooses the session's token transfer method at creation from that
238248
> header, and **defaults to `header` when it is absent** — the session comes

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rackstack-server",
3-
"version": "1.11.1",
3+
"version": "1.11.2",
44
"private": true,
55
"type": "module",
66
"scripts": {

tests/clientAuth.test.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,21 @@ function jsonResponse(body, { ok = true, status = 200 } = {}) {
3535
}
3636

3737
let originalFetch;
38+
let originalNavigator;
3839

3940
beforeEach(() => {
4041
originalFetch = globalThis.fetch;
42+
originalNavigator = globalThis.navigator;
4143
__resetAuthRefreshForTests();
4244
});
4345

4446
afterEach(() => {
4547
globalThis.fetch = originalFetch;
48+
// Plain reassignment can throw: the real `navigator` global is an accessor
49+
// with no setter, and a test may have left it in that pristine state.
50+
// Deleting first (a no-op if a test already replaced it) sidesteps that.
51+
delete globalThis.navigator;
52+
if (originalNavigator !== undefined) globalThis.navigator = originalNavigator;
4653
vi.restoreAllMocks();
4754
});
4855

@@ -377,3 +384,151 @@ describe('refresh on 401', () => {
377384
expect(res.status).toBe(401);
378385
});
379386
});
387+
388+
// The real `navigator` global (Node 21+, and every browser) exposes
389+
// `navigator` as an accessor with no setter, so a plain `globalThis.navigator
390+
// = ...` throws in this ESM test file's strict mode. Delete it first, same
391+
// as this codebase's own withRefreshLock() feature-detects its absence.
392+
function stubNavigator(value) {
393+
delete globalThis.navigator;
394+
if (value !== undefined) globalThis.navigator = value;
395+
}
396+
397+
describe('cross-tab refresh coordination', () => {
398+
it('still sends exactly one refresh for concurrent 401s when navigator.locks exists', async () => {
399+
configureAuthRefresh({ loginFlow: 'supertokens' });
400+
// A trivial single-tab-shaped stub: Web Locks exists, but only one caller
401+
// is ever asking, so it should behave exactly like having no lock at all.
402+
stubNavigator({ locks: { request: (name, fn) => fn() } });
403+
404+
let refreshes = 0;
405+
let refreshResolve;
406+
const refreshGate = new Promise((resolve) => { refreshResolve = resolve; });
407+
let refreshed = false;
408+
409+
globalThis.fetch = vi.fn(async (url) => {
410+
if (url === '/auth/session/refresh') {
411+
refreshes += 1;
412+
await refreshGate;
413+
refreshed = true;
414+
return { ok: true, status: 200, text: async () => '' };
415+
}
416+
if (!refreshed) return jsonResponse({ error: 'unauthorized' }, { ok: false, status: 401 });
417+
return jsonResponse({ ok: true });
418+
});
419+
420+
const all = Promise.all([fetchState(), fetchState(), fetchState()]);
421+
await new Promise((r) => setTimeout(r, 10));
422+
refreshResolve();
423+
await all;
424+
425+
// Proves the lock wrapper doesn't defeat the existing in-tab single-flight
426+
// guard now that a navigator.locks happens to exist.
427+
expect(refreshes).toBe(1);
428+
});
429+
430+
it('serialises refreshes across tabs so neither ever overlaps the other', async () => {
431+
// Two tabs = two independent module instances, each with its own private
432+
// inFlightRefresh closure - exactly what two separate browsing contexts
433+
// running the same bundle would have. vi.resetModules() + a fresh
434+
// dynamic import gets us that inside one test file; it does not disturb
435+
// the module instance this file's own top-level `fetchState` etc. are
436+
// bound to (those bindings were already linked when the file loaded).
437+
vi.resetModules();
438+
const tab1 = await import('../client/src/game/api.js');
439+
vi.resetModules();
440+
const tab2 = await import('../client/src/game/api.js');
441+
442+
tab1.configureAuthRefresh({ loginFlow: 'supertokens' });
443+
tab2.configureAuthRefresh({ loginFlow: 'supertokens' });
444+
445+
// Both "tabs" share one browser-wide Web Locks manager in reality; model
446+
// that with one real FIFO mutex shared by both module instances (they
447+
// already share globalThis, exactly as two tabs share it).
448+
let queue = Promise.resolve();
449+
stubNavigator({
450+
locks: {
451+
request: (name, fn) => {
452+
const run = queue.then(fn, fn);
453+
queue = run.catch(() => {});
454+
return run;
455+
},
456+
},
457+
});
458+
459+
let active = 0;
460+
let overlapped = false;
461+
let refreshCalls = 0;
462+
globalThis.fetch = vi.fn(async (url) => {
463+
if (url === '/auth/session/refresh') {
464+
refreshCalls += 1;
465+
if (active > 0) overlapped = true;
466+
active += 1;
467+
// Wide enough to make an unguarded race show up reliably.
468+
await new Promise((r) => setTimeout(r, 10));
469+
active -= 1;
470+
return { ok: true, status: 200, text: async () => '' };
471+
}
472+
// Both tabs' access tokens are expired - each retries its own request
473+
// exactly once, same as every other test in this file.
474+
return jsonResponse({ error: 'unauthorized' }, { ok: false, status: 401 });
475+
});
476+
477+
await Promise.all([tab1.fetchState(), tab2.fetchState()]);
478+
479+
// The invariant that actually prevents SuperTokens' theft detection: the
480+
// two tabs' refresh network calls never overlap in time.
481+
expect(overlapped).toBe(false);
482+
// And the fix doesn't falsely dedupe tab2's refresh away - a lock delays,
483+
// it does not merge, so each tab still gets exactly one refresh call.
484+
expect(refreshCalls).toBe(2);
485+
});
486+
487+
it('falls back to per-tab-only refresh when navigator is entirely unavailable', async () => {
488+
configureAuthRefresh({ loginFlow: 'supertokens' });
489+
stubNavigator(undefined);
490+
491+
let refreshes = 0;
492+
let refreshed = false;
493+
globalThis.fetch = vi.fn(async (url) => {
494+
if (url === '/auth/session/refresh') {
495+
refreshes += 1;
496+
refreshed = true;
497+
return { ok: true, status: 200, text: async () => '' };
498+
}
499+
if (!refreshed) return jsonResponse({ error: 'unauthorized' }, { ok: false, status: 401 });
500+
return jsonResponse({ ok: true });
501+
});
502+
503+
const res = await fetchState();
504+
505+
expect(refreshes).toBe(1);
506+
expect(res).toEqual({ ok: true });
507+
});
508+
509+
it('falls back when navigator exists but has no locks (this test runtime today)', async () => {
510+
configureAuthRefresh({ loginFlow: 'supertokens' });
511+
// Exactly the shape Node's own built-in `navigator` global has - no
512+
// `.locks` - which is why every OTHER test in this file already exercises
513+
// this path implicitly. This test pins it explicitly so it can't regress
514+
// silently if a future Node/vitest version adds navigator.locks.
515+
stubNavigator({ userAgent: 'test' });
516+
517+
let refreshes = 0;
518+
let refreshed = false;
519+
globalThis.fetch = vi.fn(async (url) => {
520+
if (url === '/auth/session/refresh') {
521+
refreshes += 1;
522+
refreshed = true;
523+
return { ok: true, status: 200, text: async () => '' };
524+
}
525+
if (!refreshed) return jsonResponse({ error: 'unauthorized' }, { ok: false, status: 401 });
526+
return jsonResponse({ ok: true });
527+
});
528+
529+
const res = await fetchState();
530+
531+
expect(refreshes).toBe(1);
532+
expect(res).toEqual({ ok: true });
533+
});
534+
});

0 commit comments

Comments
 (0)