feat(auth): use the cookie session and refresh on 401

The token is no longer stored; a tbp_session flag remembers the
login. apiFetch renews the session once on a 401 and sends the
request again. Parallel requests and other tabs share one refresh,
because Auth revokes the session when a refresh token is used twice.
Only a 401 from the refresh ends the session; a 502 or no network
shows the normal error.
This commit is contained in:
lobo committed 2026-09-25 13:45:08 +02:00
1 parent 5ff8d48c53
commit d9a39dda22
34 files changed
+292 -142

No files matched your search

+3 -2
View File
@@ -34,6 +34,8 @@ To Be Played is split into three repositories:
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
once and sends the request again; parallel requests and other tabs share one refresh.
## Tech stack ## Tech stack
@@ -85,8 +87,7 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". - Mood filter for "What to play next".
- Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in - A Content Security Policy in `nginx.conf`.
`nginx.conf`.
- Installable PWA. - Installable PWA.
## License ## License
+7 -4
View File
@@ -18,7 +18,8 @@ import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip'; import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx'; import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx'; import {usePreferences} from './context/PreferencesContext.jsx';
import {clearToken, getToken} from './utils/auth.js'; import {clearSession, hasSession} from './utils/auth.js';
import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -32,7 +33,7 @@ function App() {
useEffect(() => { useEffect(() => {
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser // No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
// clock that runs ahead would otherwise end it silently. // clock that runs ahead would otherwise end it silently.
setIsAuthenticated(!!getToken()); setIsAuthenticated(hasSession());
}, [location]); }, [location]);
// On login and on page load; the cached look shows until Auth answers. // On login and on page load; the cached look shows until Auth answers.
@@ -40,8 +41,10 @@ function App() {
if (isAuthenticated) syncAppearance(); if (isAuthenticated) syncAppearance();
}, [isAuthenticated, syncAppearance]); }, [isAuthenticated, syncAppearance]);
const handleLogout = () => { // Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
clearToken(); const handleLogout = async () => {
await logout();
clearSession();
resetAppearance(); resetAppearance();
setIsAuthenticated(false); setIsAuthenticated(false);
navigate('/login'); navigate('/login');
+4 -7
View File
@@ -10,9 +10,6 @@ vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', last
vi.mock('./hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); vi.mock('./hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const DEBOUNCE_WAIT_MS = 700; const DEBOUNCE_WAIT_MS = 700;
const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '');
// Expired by the browser clock. A clock that runs ahead of the server makes the backend still accept it.
const EXPIRED_BY_LOCAL_CLOCK = `${b64({alg: 'EdDSA'})}.${b64({exp: Math.floor(Date.now() / 1000) - 60})}.sig`;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -25,7 +22,7 @@ function Spy() {
} }
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', EXPIRED_BY_LOCAL_CLOCK); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
}); });
@@ -35,14 +32,13 @@ afterEach(() => {
}); });
describe('App', () => { describe('App', () => {
it('keeps a token that only the browser clock calls expired; a 401 from the backend ends the session', async () => { it('ends the session on a 401 that the refresh cannot fix', async () => {
render( render(
<MemoryRouter initialEntries={['/library']}> <MemoryRouter initialEntries={['/library']}>
<PreferencesProvider><App/><Spy/></PreferencesProvider> <PreferencesProvider><App/><Spy/></PreferencesProvider>
</MemoryRouter>, </MemoryRouter>,
); );
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
expect(localStorage.getItem('tbp_jwt')).toBe(EXPIRED_BY_LOCAL_CLOCK);
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})})); fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})}));
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}}); fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
@@ -50,6 +46,7 @@ describe('App', () => {
await waitFor(() => expect(current.location.pathname).toBe('/login')); await waitFor(() => expect(current.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'}); expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
expect(localStorage.getItem('tbp_jwt')).toBeNull(); expect(localStorage.getItem('tbp_session')).toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
}); });
}); });
+2 -2
View File
@@ -13,7 +13,7 @@ import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js'; import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const NOTES_MAX = 2000; const NOTES_MAX = 2000;
@@ -42,7 +42,7 @@ export default function BacklogCard({game, onSaved}) {
const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => { const save = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -12,7 +12,7 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
describe('BacklogCard', () => { describe('BacklogCard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
describe('CompletedHint', () => { describe('CompletedHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ globalThis.fetch = vi.fn(async (url, init) => ({
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}), ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
})); }));
+1 -1
View File
@@ -12,7 +12,7 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
describe('PlayingHint', () => { describe('PlayingHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
}); });
+2 -2
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js'; import useSessionExpired from './useSessionExpired.js';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const RELOAD_DELAY_MS = 4000; const RELOAD_DELAY_MS = 4000;
@@ -19,7 +19,7 @@ export default function useForceSync(onStarted) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const trigger = async () => { const trigger = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const MAX_POLL_DURATION_MS = 120_000; const MAX_POLL_DURATION_MS = 120_000;
@@ -34,7 +34,7 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
const startedAt = Date.now(); const startedAt = Date.now();
const fetchOnce = async () => { const fetchOnce = async () => {
if (!getToken()) return; if (!hasSession()) return;
try { try {
const response = await apiFetch( const response = await apiFetch(
+1 -1
View File
@@ -17,7 +17,7 @@ const wrapper = ({children}) => (
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback, useRef} from 'react'; import {useCallback, useRef} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import {useResetAppearance} from '../context/PreferencesContext.jsx'; import {useResetAppearance} from '../context/PreferencesContext.jsx';
import {clearToken} from '../utils/auth.js'; import {clearSession} from '../utils/auth.js';
/** /**
* Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which * Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
@@ -17,7 +17,7 @@ export default function useSessionExpired() {
return useCallback(() => { return useCallback(() => {
const {pathname, search} = locationRef.current; const {pathname, search} = locationRef.current;
clearToken(); clearSession();
resetAppearance(); resetAppearance();
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}}); navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
}, [navigate, resetAppearance]); }, [navigate, resetAppearance]);
+6 -6
View File
@@ -1,18 +1,18 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {sessionStamp} from '../utils/auth.js';
/** /**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only * The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
* decoration, so a failed call shows the default icon and never ends the session. Loads again for a new token * decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login
* (new Steam ID or another login) on the next render, e.g. the next navigation. * or a settings save (see sessionStamp) on the next render, e.g. the next navigation.
*/ */
export default function useSteamProfile(enabled) { export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const token = getToken(); const stamp = sessionStamp();
useEffect(() => { useEffect(() => {
if (!enabled || !token) { if (!enabled || !stamp) {
setProfile(null); setProfile(null);
return undefined; return undefined;
} }
@@ -29,7 +29,7 @@ export default function useSteamProfile(enabled) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [enabled, token]); }, [enabled, stamp]);
return profile; return profile;
} }
+8 -8
View File
@@ -6,7 +6,7 @@ import useSteamProfile from './useSteamProfile.js';
const ok = (body) => ({ok: true, status: 200, json: async () => body}); const ok = (body) => ({ok: true, status: 200, json: async () => body});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'old-token'); localStorage.setItem('tbp_session', 'first-login');
}); });
afterEach(() => { afterEach(() => {
@@ -15,21 +15,21 @@ afterEach(() => {
}); });
describe('useSteamProfile', () => { describe('useSteamProfile', () => {
it('loads again after a new token was stored, e.g. with a new Steam ID', async () => { it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
globalThis.fetch = vi.fn(async (url, init) => ok(init.headers.Authorization === 'Bearer new-token' let steamName = 'old';
? {personaName: 'New', avatarUrl: 'https://avatars.steamstatic.com/new_full.jpg'} globalThis.fetch = vi.fn(async () => ok({personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`}));
: {personaName: 'Old', avatarUrl: 'https://avatars.steamstatic.com/old_full.jpg'}));
const {result, rerender} = renderHook(() => useSteamProfile(true)); const {result, rerender} = renderHook(() => useSteamProfile(true));
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full')); await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
localStorage.setItem('tbp_jwt', 'new-token'); steamName = 'new';
localStorage.setItem('tbp_session', 'settings-saved');
rerender(); rerender();
await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full')); await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full'));
}); });
it('sends nothing without a token, e.g. right after the session expired', async () => { it('sends nothing without a session, e.g. right after it expired', async () => {
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_session');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})})); globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
renderHook(() => useSteamProfile(true)); renderHook(() => useSteamProfile(true));
+2 -2
View File
@@ -26,7 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const TOP_GAMES = 5; const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80. // The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -172,7 +172,7 @@ const Dashboard = () => {
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -20,7 +20,7 @@ const ok = (body) => ({ok: true, status: 200, json: async () => body});
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})}); const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+2 -2
View File
@@ -20,7 +20,7 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
@@ -45,7 +45,7 @@ export default function GameDetail() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -20,7 +20,7 @@ const renderDetail = () => render(
); );
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: true, status: 200, json: async () => JSON.parse(init.body)} ? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => game}); : {ok: true, status: 200, json: async () => game});
+2 -2
View File
@@ -20,7 +20,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
@@ -60,7 +60,7 @@ export default function GameLibrary() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ function renderLibrary(initialEntry = '/library') {
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
}); });
+2 -2
View File
@@ -10,7 +10,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import StatCard from '../components/StatCard.jsx'; import StatCard from '../components/StatCard.jsx';
import {getValidToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import usePublicStats from '../hooks/usePublicStats.js'; import usePublicStats from '../hooks/usePublicStats.js';
@@ -64,7 +64,7 @@ export default function LandingPage() {
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1> <h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p> <p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
<Button asChild size="lg" className="mt-10"> <Button asChild size="lg" className="mt-10">
{getValidToken() ? ( {hasSession() ? (
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link> <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
) : ( ) : (
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link> <Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
+5 -4
View File
@@ -8,7 +8,8 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import AuthLayout from '../components/AuthLayout.jsx'; import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {getValidToken, safeReturnPath, setToken} from '../utils/auth.js'; import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
import {clientFetch} from '../utils/api.js';
const LOGIN_ERROR_KEY = { const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS', INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -39,7 +40,7 @@ export default function LoginPage() {
// Build the API URL using the Vite environment variable // Build the API URL using the Vite environment variable
const apiUrl = `${import.meta.env.VITE_API_URL}/api/auth/login`; const apiUrl = `${import.meta.env.VITE_API_URL}/api/auth/login`;
const response = await fetch(apiUrl, { const response = await clientFetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -51,7 +52,7 @@ export default function LoginPage() {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (response.ok) { if (response.ok) {
setToken(data.token); markSession();
navigate(target, {replace: true}); navigate(target, {replace: true});
} else { } else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
@@ -72,7 +73,7 @@ export default function LoginPage() {
} }
}; };
if (getValidToken()) { if (hasSession()) {
return <Navigate to={target} replace/>; return <Navigate to={target} replace/>;
} }
+6 -5
View File
@@ -6,9 +6,6 @@ import LoginPage from './LoginPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '');
const TOKEN = `${b64({alg: 'EdDSA'})}.${b64({exp: Math.floor(Date.now() / 1000) + 3600})}.sig`;
function CurrentPath() { function CurrentPath() {
const location = useLocation(); const location = useLocation();
return <div data-testid="path">{location.pathname + location.search}</div>; return <div data-testid="path">{location.pathname + location.search}</div>;
@@ -33,7 +30,7 @@ function submit() {
describe('LoginPage', () => { describe('LoginPage', () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({token: TOKEN})})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({message: 'Login successful'})}));
}); });
afterEach(() => { afterEach(() => {
@@ -59,7 +56,11 @@ describe('LoginPage', () => {
submit(); submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING')); await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING'));
expect(localStorage.getItem('tbp_jwt')).toBe(TOKEN); expect(localStorage.getItem('tbp_session')).not.toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/login'), expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({'X-TBP-Client': 'web'}),
}));
}); });
it('ignores a return path outside the app', async () => { it('ignores a return path outside the app', async () => {
+2 -2
View File
@@ -14,7 +14,7 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js'; import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data. // maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
const BUDGETS = [ const BUDGETS = [
@@ -47,7 +47,7 @@ export default function PlayNextPage() {
const latestRequest = useRef(0); const latestRequest = useRef(0);
const load = useCallback(async (maxMinutes, exclude) => { const load = useCallback(async (maxMinutes, exclude) => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -17,7 +17,7 @@ const pickerCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.incl
describe('PlayNextPage on a language change', () => { describe('PlayNextPage on a language change', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
i18n.t = (key) => key; i18n.t = (key) => key;
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})}));
}); });
+1 -1
View File
@@ -36,7 +36,7 @@ function renderPage() {
describe('PlayNextPage', () => { describe('PlayNextPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+2 -1
View File
@@ -12,6 +12,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import {registrationSchema} from '../utils/validation.js'; import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
@@ -46,7 +47,7 @@ export default function RegistrationPage() {
setFeedback({message: '', isError: false}); setFeedback({message: '', isError: false});
try { try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`; const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
const response = await fetch(apiUrl, { const response = await clientFetch(apiUrl, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
+5 -5
View File
@@ -23,7 +23,7 @@ import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken, setToken} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
@@ -63,7 +63,7 @@ export default function SettingsPage() {
}); });
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -107,7 +107,7 @@ export default function SettingsPage() {
}, [navigate, expireSession, reset, t]); }, [navigate, expireSession, reset, t]);
const persistSettings = async (payload) => { const persistSettings = async (payload) => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return {ok: false}; return {ok: false};
} }
@@ -133,8 +133,8 @@ export default function SettingsPage() {
return {ok: false}; return {ok: false};
} }
const data = await response.json(); // Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
setToken(data.token); // Core reads the steamId from the token markSession();
toast.success(t('SETTINGS.SAVED')); toast.success(t('SETTINGS.SAVED'));
return {ok: true}; return {ok: true};
} catch (err) { } catch (err) {
+4 -3
View File
@@ -21,13 +21,14 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
function renderPage(me) { function renderPage(me) {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: true, status: 200, json: async () => ({token: 'token'})} // Both PUTs answer 204 without a body, like Auth does.
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
: {ok: true, status: 200, json: async () => me})); : {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
} }
describe('SettingsPage public profile', () => { describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -76,7 +77,7 @@ describe('SettingsPage public profile', () => {
}); });
describe('SettingsPage appearance', () => { describe('SettingsPage appearance', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
+2 -2
View File
@@ -12,7 +12,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const formatAdded = (instant, locale) => const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null; instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
@@ -26,7 +26,7 @@ export default function WishlistPage() {
const [onlyOnSale, setOnlyOnSale] = useState(false); const [onlyOnSale, setOnlyOnSale] = useState(false);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return undefined; return undefined;
} }
+3 -3
View File
@@ -27,7 +27,7 @@ const renderPage = () => render(
describe('WishlistPage', () => { describe('WishlistPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist}));
}); });
@@ -64,8 +64,8 @@ describe('WishlistPage', () => {
expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy(); expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy();
}); });
it('opens the login page on a 401', async () => { it('opens the login page on a 401 that the refresh cannot fix', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 401, json: async () => ({})}); fetch.mockResolvedValue({ok: false, status: 401, json: async () => ({})});
renderPage(); renderPage();
await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true)); await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true));
+54 -6
View File
@@ -1,10 +1,58 @@
import {getToken} 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';
/** /**
* fetch with the login token. The caller handles the response status, 401 included. * fetch with the login cookies and the client header, without a refresh. For login, registration, refresh and
* logout, where a 401 is an answer and not an expired session.
*/ */
export function apiFetch(url, init = {}) { export function clientFetch(url, init = {}) {
const token = getToken(); return fetch(url, {...init, credentials: 'include', headers: {...init.headers, [CLIENT_HEADER]: 'web'}});
const headers = token ? {...init.headers, 'Authorization': `Bearer ${token}`} : init.headers; }
return fetch(url, {...init, headers});
/**
* Revokes the session in Auth and clears both cookies. A failure is ignored: the page logs out anyway.
*/
export const logout = () =>
clientFetch(`${import.meta.env.VITE_API_URL}/api/auth/logout`, {method: 'POST'}).catch(() => undefined);
const REFRESH_TIMEOUT_MS = 10_000;
let runningRefresh = null;
/**
* Renews the access cookie. False when Auth says the session is over (401). Any other failure, e.g. a 502 during a
* deploy or no network, rejects: the session may still be fine, so the caller shows its network error instead.
* Parallel callers share one request. The lock also holds other tabs back: Auth revokes the whole session when one
* refresh token is used twice.
*/
export function refreshSession() {
if (!runningRefresh) {
const refresh = async () => {
// A hung request would hold the lock, and with it the refresh in every tab.
const response = await clientFetch(`${import.meta.env.VITE_API_URL}/api/auth/refresh`,
{method: 'POST', signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS)});
if (response.ok || response.status === 401) {
return response.ok;
}
throw new Error(`Session refresh failed with ${response.status}`);
};
const locked = globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', refresh) : refresh();
runningRefresh = locked.finally(() => {
runningRefresh = null;
});
}
return runningRefresh;
}
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails,
* the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees
* why and comes back to the same page.
*/
export async function apiFetch(url, init = {}) {
const response = await clientFetch(url, init);
if (response.status !== 401 || !(await refreshSession())) {
return response;
}
return clientFetch(url, init);
} }
+113 -15
View File
@@ -1,40 +1,138 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch} from './api.js'; import {apiFetch, clientFetch, logout} from './api.js';
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
const dataCalls = () => fetch.mock.calls.filter(([url]) => !isRefresh(url));
const refreshCalls = () => fetch.mock.calls.filter(([url]) => isRefresh(url));
describe('apiFetch', () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204})); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
it('sends the stored token and keeps the other headers and options', async () => { describe('clientFetch', () => {
localStorage.setItem('tbp_jwt', 'the-token'); it('sends the cookies and the client header and keeps the other options', async () => {
globalThis.fetch = vi.fn(async () => response(204));
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'}); await clientFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
expect(fetch).toHaveBeenCalledWith('/api/x', { expect(fetch).toHaveBeenCalledWith('/api/x', {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'}, headers: {'Content-Type': 'application/json', 'X-TBP-Client': 'web'},
body: '{}', body: '{}',
credentials: 'include',
});
}); });
}); });
it('sends no Authorization header without a token', async () => { describe('apiFetch', () => {
it('renews the session once on a 401 and sends the request again', async () => {
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
const result = await apiFetch('/api/x', {method: 'PUT', body: '{"a":1}'});
expect(result.status).toBe(200);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(2);
expect(dataCalls()[1][1]).toMatchObject({method: 'PUT', body: '{"a":1}'});
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('asks only once when parallel calls get a 401', async () => {
const seen = new Set();
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) return response(204);
if (seen.has(url)) return response(200);
seen.add(url);
return response(401);
});
const results = await Promise.all(['/a', '/b', '/c'].map((url) => apiFetch(url)));
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
expect(refreshCalls()).toHaveLength(1);
});
it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
expect(result.status).toBe(401);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(1);
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when the refresh has no network', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('offline');
return response(401);
});
await expect(apiFetch('/api/x')).rejects.toThrow('offline');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when Auth is down during a deploy', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 502 : 401));
await expect(apiFetch('/api/x')).rejects.toThrow('502');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('tries the refresh again on the next 401 after a failed one', async () => {
const refreshAnswers = [502, 204];
const dataAnswers = [401, 401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? refreshAnswers.shift() : dataAnswers.shift()));
await expect(apiFetch('/api/x')).rejects.toThrow();
expect((await apiFetch('/api/x')).status).toBe(200);
});
it('passes other errors through without a refresh', async () => {
globalThis.fetch = vi.fn(async () => response(403));
expect((await apiFetch('/api/x')).status).toBe(403);
expect(refreshCalls()).toHaveLength(0);
});
it('gives up on a refresh that hangs, so the lock does not block every tab', async () => {
globalThis.fetch = vi.fn(async (url, init) => {
if (!isRefresh(url)) return response(401);
expect(init.signal).toBeInstanceOf(AbortSignal);
throw new DOMException('timed out', 'TimeoutError');
});
await expect(apiFetch('/api/x')).rejects.toThrow('timed out');
});
it('runs the refresh under a lock that other tabs share', async () => {
const request = vi.fn((name, callback) => callback());
vi.stubGlobal('navigator', {...navigator, locks: {request}});
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
await apiFetch('/api/x'); await apiFetch('/api/x');
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined}); expect(request).toHaveBeenCalledWith('tbp-refresh', expect.any(Function));
vi.unstubAllGlobals();
});
}); });
it('reads the token on every call', async () => { describe('logout', () => {
localStorage.setItem('tbp_jwt', 'old'); it('posts to Auth and ignores a network error', async () => {
await apiFetch('/api/x'); globalThis.fetch = vi.fn(async () => {
localStorage.setItem('tbp_jwt', 'new'); throw new TypeError('offline');
await apiFetch('/api/x'); });
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}}); await expect(logout()).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/logout'), expect.objectContaining({method: 'POST'}));
}); });
}); });
+17 -27
View File
@@ -1,35 +1,25 @@
const TOKEN_KEY = 'tbp_jwt'; // The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
// stale flag costs one 401 and a failed refresh, then the login page opens.
const SESSION_KEY = 'tbp_session';
// Where the token lived before the cookies. Removed on the next login or logout.
const LEGACY_TOKEN_KEY = 'tbp_jwt';
export const getToken = () => localStorage.getItem(TOKEN_KEY); export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
export const setToken = (token) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
/** /**
* Only checks `exp`. The signature is checked by the backends. * Changes on every login and on every settings save, so a hook that shows the user's Steam data can load again.
*/ */
export function isTokenValid(token, nowMs = Date.now()) { export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
try {
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const {exp} = JSON.parse(atob(payload));
return typeof exp === 'number' && exp * 1000 > nowMs;
} catch {
return false;
}
}
/** export const markSession = () => {
* Returns the stored token, or null. Removes an expired token. localStorage.setItem(SESSION_KEY, String(Date.now()));
*/ localStorage.removeItem(LEGACY_TOKEN_KEY);
export function getValidToken() { };
const token = getToken();
if (token && !isTokenValid(token)) { export const clearSession = () => {
clearToken(); localStorage.removeItem(SESSION_KEY);
return null; localStorage.removeItem(LEGACY_TOKEN_KEY);
} };
return token;
}
/** /**
* Returns the path when it is a path inside this app, else null. The login page uses it to go back after a * Returns the path when it is a path inside this app, else null. The login page uses it to go back after a
+26 -17
View File
@@ -1,28 +1,37 @@
import {describe, expect, it} from 'vitest'; // @vitest-environment jsdom
import {isTokenValid, safeReturnPath} from './auth.js'; import {afterEach, describe, expect, it} from 'vitest';
import {clearSession, hasSession, markSession, safeReturnPath, sessionStamp} from './auth.js';
const tokenWithPayload = (payload) => { afterEach(() => localStorage.clear());
const encode = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return `${encode({alg: 'EdDSA'})}.${encode(payload)}.signature`;
};
describe('isTokenValid', () => { describe('session flag', () => {
const now = Date.UTC(2026, 8, 24, 12, 0, 0); it('is set by a login and removed by clearSession', () => {
expect(hasSession()).toBe(false);
it('accepts a token that expires in the future', () => { markSession();
expect(isTokenValid(tokenWithPayload({exp: now / 1000 + 60}), now)).toBe(true); expect(hasSession()).toBe(true);
clearSession();
expect(hasSession()).toBe(false);
}); });
it('rejects an expired token', () => { it('removes the token of the old localStorage login', () => {
expect(isTokenValid(tokenWithPayload({exp: now / 1000 - 60}), now)).toBe(false); localStorage.setItem('tbp_jwt', 'old');
markSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
localStorage.setItem('tbp_jwt', 'old');
clearSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
}); });
it('rejects a token without exp', () => { it('gives a new stamp on every mark', async () => {
expect(isTokenValid(tokenWithPayload({sub: 'a@b.de'}), now)).toBe(false); markSession();
}); const first = sessionStamp();
await new Promise((resolve) => setTimeout(resolve, 2));
markSession();
it.each([null, '', 'not-a-jwt', 'a.%%%.c'])('rejects %j', (token) => { expect(sessionStamp()).not.toBe(first);
expect(isTokenValid(token, now)).toBe(false);
}); });
}); });