Public Access
refactor(session): one session store for login, logout and 401
This commit is contained in:
1 parent
2a22789435
commit
cbafa3d540
23 files changed
+246
-280
No files matched your search
+6
-8
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user