Public Access
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:
1 parent
5ff8d48c53
commit
d9a39dda22
34 files changed
+297
-147
No files matched your search
@@ -26,7 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {getToken} from '../utils/auth.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
@@ -172,7 +172,7 @@ const Dashboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const ok = (body) => ({ok: true, status: 200, json: async () => body});
|
||||
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {getToken} from '../utils/auth.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const formatUnlockedAt = (instant, locale) => {
|
||||
if (!instant) return '';
|
||||
@@ -45,7 +45,7 @@ export default function GameDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const renderDetail = () => render(
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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 () => game});
|
||||
|
||||
@@ -20,7 +20,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
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 DEFAULT_SIZE = 20;
|
||||
@@ -60,7 +60,7 @@ export default function GameLibrary() {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function renderLibrary(initialEntry = '/library') {
|
||||
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.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';
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function LandingPage() {
|
||||
<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>
|
||||
<Button asChild size="lg" className="mt-10">
|
||||
{getValidToken() ? (
|
||||
{hasSession() ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import AuthLayout from '../components/AuthLayout.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 = {
|
||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||
@@ -39,7 +40,7 @@ export default function LoginPage() {
|
||||
// Build the API URL using the Vite environment variable
|
||||
const apiUrl = `${import.meta.env.VITE_API_URL}/api/auth/login`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
const response = await clientFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -51,7 +52,7 @@ export default function LoginPage() {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.ok) {
|
||||
setToken(data.token);
|
||||
markSession();
|
||||
navigate(target, {replace: true});
|
||||
} else {
|
||||
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/>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,6 @@ import LoginPage from './LoginPage.jsx';
|
||||
|
||||
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() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="path">{location.pathname + location.search}</div>;
|
||||
@@ -33,7 +30,7 @@ function submit() {
|
||||
|
||||
describe('LoginPage', () => {
|
||||
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(() => {
|
||||
@@ -59,7 +56,11 @@ describe('LoginPage', () => {
|
||||
submit();
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.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.
|
||||
const BUDGETS = [
|
||||
@@ -47,7 +47,7 @@ export default function PlayNextPage() {
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async (maxMinutes, exclude) => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const pickerCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.incl
|
||||
|
||||
describe('PlayNextPage on a language change', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
i18n.t = (key) => key;
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})}));
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ function renderPage() {
|
||||
|
||||
describe('PlayNextPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
|
||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import {registrationSchema} from '../utils/validation.js';
|
||||
import {clientFetch} from '../utils/api.js';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
|
||||
@@ -46,7 +47,7 @@ export default function RegistrationPage() {
|
||||
setFeedback({message: '', isError: false});
|
||||
try {
|
||||
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
|
||||
const response = await fetch(apiUrl, {
|
||||
const response = await clientFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -23,7 +23,7 @@ import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {getToken, setToken} from '../utils/auth.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
@@ -63,7 +63,7 @@ export default function SettingsPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export default function SettingsPage() {
|
||||
}, [navigate, expireSession, reset, t]);
|
||||
|
||||
const persistSettings = async (payload) => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return {ok: false};
|
||||
}
|
||||
@@ -133,8 +133,8 @@ export default function SettingsPage() {
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setToken(data.token); // Core reads the steamId from the token
|
||||
// Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
|
||||
markSession();
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
return {ok: true};
|
||||
} catch (err) {
|
||||
|
||||
@@ -21,13 +21,14 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
||||
|
||||
function renderPage(me) {
|
||||
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}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('SettingsPage public profile', () => {
|
||||
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -76,7 +77,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
describe('SettingsPage appearance', () => {
|
||||
beforeEach(() => localStorage.setItem('tbp_jwt', 'token'));
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -12,7 +12,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {getToken} from '../utils/auth.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const formatAdded = (instant, locale) =>
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const renderPage = () => render(
|
||||
|
||||
describe('WishlistPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_jwt', 'token');
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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();
|
||||
});
|
||||
|
||||
it('opens the login page on a 401', async () => {
|
||||
fetch.mockResolvedValueOnce({ok: false, status: 401, json: async () => ({})});
|
||||
it('opens the login page on a 401 that the refresh cannot fix', async () => {
|
||||
fetch.mockResolvedValue({ok: false, status: 401, json: async () => ({})});
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true));
|
||||
|
||||
Reference in new issue
Block a user