Log out only when the refresh token is rejected

Any failed refresh (offline, 5xx, 429) cleared both tokens, and each
expired request started its own refresh, so a burst of 401s could
trip the refresh rate limit and log the tab out. Page load also wiped
tokens on any /auth/me failure. Share one in-flight refresh, end the
session only on a 401/422 from /auth/refresh or /auth/me, and let
transient failures surface as errors with the tokens intact.
This commit is contained in:
Juan Denis committed 2026-10-26 00:46:13 -04:00
1 parent f724cf0067
commit cb1422f34f
3 files changed
+104 -21

No files matched your search

+5 -4
View File
@@ -47,10 +47,11 @@ export function AuthProvider({ children }) {
const data = await api.getCurrentUser();
setUser(data.user);
} catch (error) {
if (error.status !== 401) {
console.error('Auth check failed:', error);
}
api.clearTokens();
// Only a 401 means the session is gone. A restarting backend
// (502, "database is locked") or a dropped connection must not
// delete a refresh token that is still good.
if (error.status === 401) api.clearTokens();
else console.error('Auth check failed:', error);
}
}
setLoading(false);
@@ -0,0 +1,71 @@
import test, { beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import ApiClient from '../client.js';
// A browser-ish world: localStorage, window events, and a scripted fetch.
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
const events = [];
globalThis.window ??= globalThis;
globalThis.window.dispatchEvent = (e) => events.push(e.type);
const json = (status, body) => new Response(JSON.stringify(body), {
status, headers: { 'Content-Type': 'application/json' },
});
const accessKey = () => [...store.keys()].find((k) => k.endsWith('access_token'));
const refreshKey = () => [...store.keys()].find((k) => k.endsWith('refresh_token'));
function client() {
const c = new ApiClient();
c.setTokens('expired', 'refresh-ok');
return c;
}
beforeEach(() => { store.clear(); events.length = 0; });
test('parallel expired requests share one refresh', async () => {
const c = client();
let refreshes = 0;
globalThis.fetch = async (url, init) => {
if (url.endsWith('/auth/refresh')) {
refreshes += 1;
await new Promise((r) => setTimeout(r, 10));
return json(200, { access_token: 'fresh' });
}
return init.headers.Authorization === 'Bearer fresh'
? json(200, { ok: true })
: json(401, { msg: 'Token has expired' });
};
const results = await Promise.all([1, 2, 3].map((n) => c._request(`/things/${n}`)));
assert.deepEqual(results, [{ ok: true }, { ok: true }, { ok: true }]);
assert.equal(refreshes, 1);
});
for (const failure of ['offline', 500, 429]) {
test(`a transient refresh failure (${failure}) keeps the session`, async () => {
const c = client();
globalThis.fetch = async (url) => {
if (!url.endsWith('/auth/refresh')) return json(401, { msg: 'Token has expired' });
if (failure === 'offline') throw new TypeError('Failed to fetch');
return json(failure, { error: 'busy' });
};
await assert.rejects(c._request('/things'));
assert.equal(store.get(refreshKey()), 'refresh-ok');
assert.deepEqual(events, []);
});
}
test('a rejected refresh token ends the session', async () => {
const c = client();
globalThis.fetch = async (url) => (url.endsWith('/auth/refresh')
? json(401, { msg: 'Token has been revoked' })
: json(401, { msg: 'Token has expired' }));
await assert.rejects(c._request('/things'), (err) => err.status === 401);
assert.equal(accessKey(), undefined);
assert.equal(refreshKey(), undefined);
assert.deepEqual(events, ['serverkit:auth-expired']);
});
+28 -17
View File
@@ -174,6 +174,8 @@ class ApiClient {
const isJwtIssue = probe && probe.msg && !probe.error;
if (isJwtIssue) {
// Throws on a transient refresh failure (offline, 5xx, 429):
// the refresh token is still good, so the session must survive.
const refreshed = await this.refreshToken();
if (refreshed) {
config.headers.Authorization = `Bearer ${this.getToken()}`;
@@ -223,28 +225,37 @@ class ApiClient {
return data;
}
async refreshToken() {
// True once a new access token is stored; false only when the server
// rejected the refresh token (401/422), which is the one case that ends the
// session. Anything else throws and leaves the tokens alone. Concurrent
// callers share one in-flight refresh, so a burst of expired requests costs
// one POST /auth/refresh instead of one each (and can't trip its limit).
refreshToken() {
this._refreshing ??= this._refreshOnce().finally(() => { this._refreshing = null; });
return this._refreshing;
}
async _refreshOnce() {
const refreshToken = localStorage.getItem(storageKey('refresh_token'));
if (!refreshToken) return false;
try {
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshToken}`,
},
});
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshToken}`,
},
});
if (response.ok) {
const data = await response.json();
localStorage.setItem(storageKey('access_token'), data.access_token);
return true;
}
return false;
} catch {
return false;
if (response.ok) {
const data = await response.json();
localStorage.setItem(storageKey('access_token'), data.access_token);
return true;
}
if (response.status === 401 || response.status === 422) return false;
const err = new Error(`Could not refresh the session (${response.status})`);
err.status = response.status;
throw err;
}
}