refactor(session): one session store for login, logout and 401

This commit is contained in:
lobo committed 2026-09-30 07:36:08 +02:00
1 parent 2a22789435
commit cbafa3d540
23 files changed
+246 -280

No files matched your search

+6 -8
View File
@@ -1,3 +1,5 @@
import {expireSession, sessionStamp} from './auth.js';
// Auth and Core refuse a request that changes data without this header (CSRF guard, see ClientHeaderFilter).
export const CLIENT_HEADER = 'X-TBP-Client';
@@ -54,21 +56,17 @@ export async function rotatingFetch(url, init = {}) {
return globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', send) : send();
}
/**
* Fires `expired` when the session is over. RequireSession ends it, so the user sees why and comes back to the same
* page; a public page does not listen.
*/
export const sessionEvents = new EventTarget();
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. A 401 after that
* goes back to the caller and fires `expired`; the caller only stops.
* ends the session (RequireSession then opens the login page, a public page stays) and goes back to the caller,
* which only stops.
*/
export async function apiFetch(url, init = {}) {
const stamp = sessionStamp();
let response = await clientFetch(url, init);
if (response.status === 401) {
if (await refreshSession()) response = await clientFetch(url, init);
if (response.status === 401) sessionEvents.dispatchEvent(new Event('expired'));
if (response.status === 401) expireSession(stamp);
}
return response;
}
+38 -16
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch, clientFetch, logout, request, sessionEvents} from './api.js';
import {apiFetch, clientFetch, logout, request} from './api.js';
import {clearSession, markSession, sessionEndReason, subscribeSession} from './auth.js';
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
@@ -59,7 +60,7 @@ describe('apiFetch', () => {
expect(refreshCalls()).toHaveLength(1);
});
it('returns the 401 when the refresh fails, without a loop', async () => {
it('returns the 401 when the refresh fails, without a loop, and ends the session', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
@@ -67,7 +68,7 @@ describe('apiFetch', () => {
expect(result.status).toBe(401);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(1);
expect(localStorage.getItem('tbp_session')).toBe('1');
expect(localStorage.getItem('tbp_session')).toBeNull();
});
it('keeps the session and rejects when the refresh has no network', async () => {
@@ -126,36 +127,56 @@ describe('apiFetch', () => {
});
});
describe('the expired event', () => {
let expired;
const count = () => expired.mock.calls.length;
describe('the session end on a 401', () => {
let changes;
let unsubscribe;
beforeEach(() => {
expired = vi.fn();
sessionEvents.addEventListener('expired', expired);
markSession();
changes = vi.fn();
unsubscribe = subscribeSession(changes);
});
afterEach(() => {
sessionEvents.removeEventListener('expired', expired);
unsubscribe();
});
it('fires once when the refresh says the session is over', async () => {
it('ends the session once when the refresh says it is over', async () => {
globalThis.fetch = vi.fn(async () => response(401));
await apiFetch('/api/x');
await Promise.all([apiFetch('/api/x'), apiFetch('/api/y')]);
expect(count()).toBe(1);
expect(localStorage.getItem('tbp_session')).toBeNull();
expect(sessionEndReason()).toBe('expired');
expect(changes).toHaveBeenCalledTimes(1);
});
it('fires when the request after a good refresh still gets a 401', async () => {
it('keeps a new login when the refresh of the session before it fails late', async () => {
let failRefresh;
globalThis.fetch = vi.fn((url) => isRefresh(url)
? new Promise((resolve) => { failRefresh = () => resolve(response(401)); })
: Promise.resolve(response(401)));
const pending = apiFetch('/api/x');
await vi.waitFor(() => expect(failRefresh).toBeDefined());
clearSession();
markSession();
failRefresh();
await pending;
expect(localStorage.getItem('tbp_session')).not.toBeNull();
expect(sessionEndReason()).toBeNull();
});
it('ends it when the request after a good refresh still gets a 401', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : 401));
await apiFetch('/api/x');
expect(count()).toBe(1);
expect(sessionEndReason()).toBe('expired');
});
it('does not fire when the refresh has no network, or for clientFetch', async () => {
it('keeps it when the refresh has no network, and for clientFetch', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('Failed to fetch');
return response(401);
@@ -164,7 +185,8 @@ describe('the expired event', () => {
await expect(apiFetch('/api/x')).rejects.toThrow();
await clientFetch('/api/x');
expect(count()).toBe(0);
expect(localStorage.getItem('tbp_session')).not.toBeNull();
expect(changes).not.toHaveBeenCalled();
});
});
+43 -1
View File
@@ -11,16 +11,58 @@ export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
*/
export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
const listeners = new Set();
// Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'.
let endReason = null;
const change = (reason) => {
endReason = reason;
listeners.forEach((listener) => listener());
};
export const sessionEndReason = () => endReason;
const onStorage = (event) => {
if (event.key === SESSION_KEY || event.key === null) change(null);
};
export const markSession = () => {
localStorage.setItem(SESSION_KEY, String(Date.now()));
// New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
localStorage.setItem(SESSION_KEY, crypto.randomUUID());
localStorage.removeItem(LEGACY_TOKEN_KEY);
change(null);
};
export const clearSession = () => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
change('logout');
};
/**
* Ends the session after a 401 that the refresh could not fix; the login page then says why. Parallel requests may
* each call it. Only the session with `stamp` can expire: a late answer for the session before a new login must not
* end the new one.
*/
export const expireSession = (stamp = sessionStamp()) => {
if (stamp === null || sessionStamp() !== stamp) return;
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
change('expired');
};
/**
* Calls `listener` on every change of the session, also on a login or logout in another tab.
*/
export function subscribeSession(listener) {
if (listeners.size === 0) window.addEventListener('storage', onStorage);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) window.removeEventListener('storage', onStorage);
};
}
/**
* Returns the path when it is a path inside this app, else null. The login page uses it to go back after a
* session expired; "//host", "/\host" and tabs or line breaks (browsers drop them, so "/\t/host" is "//host")