refactor: move access token to HTTP-only cookie

This commit is contained in:
Darkraise
2026-02-09 11:29:40 +07:00
parent ee9c2a296f
commit e173ce721e
16 changed files with 239 additions and 308 deletions
@@ -326,10 +326,14 @@ export class GameDataDownloader {
if (resolvedAddress) {
requestOptions.lookup = (
_hostname: string,
_options: unknown,
callback: (err: Error | null, address: string, family: number) => void
options: { all?: boolean },
callback: (...args: unknown[]) => void
) => {
callback(null, resolvedAddress!, resolvedFamily!);
if (options && options.all) {
callback(null, [{ address: resolvedAddress, family: resolvedFamily }]);
} else {
callback(null, resolvedAddress, resolvedFamily);
}
};
}
+65 -62
View File
@@ -4,10 +4,27 @@ import { AppError } from './errorHandler';
import { verifySharedAccessToken, SharedAccessPayload } from '../utils/jwt';
import { UserPlaylistService } from '../services/UserPlaylistService';
import { asyncHandler } from './asyncHandler';
import { getAccessTokenFromCookie } from '../utils/cookies';
const authService = new AuthService();
const playlistService = new UserPlaylistService();
/**
* Extract access token from HTTP-only cookie or Authorization header.
* Cookie takes priority (regular authenticated user).
*/
function getAccessToken(req: Request): string | undefined {
const cookieToken = getAccessTokenFromCookie(req.cookies);
if (cookieToken) return cookieToken;
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return undefined;
}
/**
* Extend Express Request to include sharedAccess property
*/
@@ -21,19 +38,15 @@ declare global {
/**
* Authenticate user from JWT token
* Requires valid token in Authorization header
* Reads access token from HTTP-only cookie first, falls back to Authorization header
*/
export const authenticate = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const token = getAccessToken(req);
if (!token) {
throw new AppError(401, 'No token provided', true, 'NO_TOKEN');
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
// Verify token and get user
const user = await authService.verifyAccessToken(token);
req.user = user;
@@ -43,15 +56,13 @@ export const authenticate = asyncHandler(
/**
* Optional authentication - allows guest access if enabled
* If token is provided, verifies it. Otherwise, sets guest user (if guest access enabled)
* Reads token from cookie or header. If absent, sets guest user (if guest access enabled)
*/
export const optionalAuth = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
const token = getAccessToken(req);
if (authHeader && authHeader.startsWith('Bearer ')) {
// Token provided - verify it
const token = authHeader.substring(7);
if (token) {
const user = await authService.verifyAccessToken(token);
req.user = user;
} else {
@@ -78,30 +89,26 @@ export const optionalAuth = asyncHandler(
/**
* Soft authentication - populates req.user if token exists, otherwise leaves undefined
* Does NOT throw errors - just silently sets req.user or leaves it undefined
* Used for middleware that needs to check user identity but doesn't require auth
* Perfect for maintenance mode checks that need to identify admins without blocking others
* Reads token from cookie or header. Perfect for maintenance mode checks.
*/
export const softAuth = async (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
const token = getAccessToken(req);
if (authHeader && authHeader.startsWith('Bearer ')) {
// Token provided - try to verify it
const token = authHeader.substring(7);
if (token) {
try {
const user = await authService.verifyAccessToken(token);
req.user = user;
} catch (error) {
} catch {
// Invalid/expired token - just leave req.user undefined
req.user = undefined;
}
} else {
// No token - leave req.user undefined
req.user = undefined;
}
next(); // Always continue, never throw errors
} catch (error) {
} catch {
// Unexpected error - continue anyway with undefined user
req.user = undefined;
next();
@@ -110,64 +117,60 @@ export const softAuth = async (req: Request, res: Response, next: NextFunction)
/**
* Middleware that handles both regular authentication and shared access tokens
* Order: 1) Regular JWT Bearer token -> req.user
* 2) Shared access token (SharedAccess prefix) -> req.sharedAccess
* Order: 1) Regular JWT (cookie or Bearer header) -> req.user
* 2) Shared access token (SharedAccess header) -> req.sharedAccess
* 3) Guest access (if enabled) -> req.user (guest)
*/
export const sharedAccessAuth = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
// 1. Check regular auth (cookie or Bearer header)
const accessToken = getAccessToken(req);
if (accessToken) {
const user = await authService.verifyAccessToken(accessToken);
req.user = user;
return next();
}
// 2. Check for shared access token (Authorization header only)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('SharedAccess ')) {
const token = authHeader.substring(13);
try {
const payload = verifySharedAccessToken(token);
if (authHeader) {
// Check for shared access token first
if (authHeader.startsWith('SharedAccess ')) {
const token = authHeader.substring(13);
try {
const payload = verifySharedAccessToken(token);
// Validate the shareToken is still valid
const playlist = playlistService.getPlaylistByShareToken(payload.shareToken);
if (!playlist) {
throw new AppError(
401,
'Shared access token invalid or expired',
true,
'SHARED_ACCESS_INVALID'
);
}
// Grant games.play permission for valid shared access tokens
req.sharedAccess = payload;
req.user = {
id: 0,
username: 'shared-guest',
email: '',
role: 'guest',
permissions: ['games.read', 'playlists.read', 'games.play'],
};
return next();
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(401, 'Invalid shared access token', true, 'SHARED_ACCESS_INVALID');
// Validate the shareToken is still valid
const playlist = playlistService.getPlaylistByShareToken(payload.shareToken);
if (!playlist) {
throw new AppError(
401,
'Shared access token invalid or expired',
true,
'SHARED_ACCESS_INVALID'
);
}
}
// Check for regular JWT token
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const user = await authService.verifyAccessToken(token);
req.user = user;
// Grant games.play permission for valid shared access tokens
req.sharedAccess = payload;
req.user = {
id: 0,
username: 'shared-guest',
email: '',
role: 'guest',
permissions: ['games.read', 'playlists.read', 'games.play'],
};
return next();
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(401, 'Invalid shared access token', true, 'SHARED_ACCESS_INVALID');
}
}
// No valid token - check guest access
// 3. No valid token - check guest access
if (!authService.isGuestAccessEnabled()) {
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
}
// Guest users without a shared access token can only browse, not play
// (games.play is only granted when a valid shared access token is verified above)
req.user = {
id: 0,
username: 'guest',
+18 -9
View File
@@ -12,6 +12,9 @@ import {
setRefreshTokenCookie,
clearRefreshTokenCookie,
getRefreshTokenFromCookie,
setAccessTokenCookie,
clearAccessTokenCookie,
getAccessTokenFromCookie,
} from '../utils/cookies';
import { z } from 'zod';
@@ -145,13 +148,13 @@ router.post(
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
// Set refresh token as HTTP-only cookie
// Set tokens as HTTP-only cookies
setRefreshTokenCookie(res, result.tokens.refreshToken);
setAccessTokenCookie(res, result.tokens.accessToken);
res.json({
user: result.user,
tokens: {
accessToken: result.tokens.accessToken,
expiresIn: result.tokens.expiresIn,
},
});
@@ -236,13 +239,13 @@ router.post(
logger.info(`[Auth] Registration successful for user: ${data.username}`);
// Set refresh token as HTTP-only cookie
// Set tokens as HTTP-only cookies
setRefreshTokenCookie(res, result.tokens.refreshToken);
setAccessTokenCookie(res, result.tokens.accessToken);
res.status(201).json({
user: result.user,
tokens: {
accessToken: result.tokens.accessToken,
expiresIn: result.tokens.expiresIn,
},
});
@@ -285,6 +288,7 @@ router.post(
}
clearRefreshTokenCookie(res);
clearAccessTokenCookie(res);
res.json({ success: true, message: 'Logged out successfully' });
})
@@ -306,11 +310,11 @@ router.post(
const tokens = await authService.refreshToken(refreshToken);
// Set new refresh token cookie (token rotation)
// Set new token cookies (token rotation)
setRefreshTokenCookie(res, tokens.refreshToken);
setAccessTokenCookie(res, tokens.accessToken);
res.json({
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
})
@@ -323,12 +327,17 @@ router.post(
router.get(
'/me',
asyncHandler(async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// Read access token from cookie first, fallback to Authorization header
const token =
getAccessTokenFromCookie(req.cookies) ||
(req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.substring(7)
: undefined);
if (!token) {
throw new AppError(401, 'No token provided');
}
const token = authHeader.substring(7);
const user = await authService.verifyAccessToken(token);
res.json(user);
+40 -4
View File
@@ -1,9 +1,14 @@
import { CookieOptions, Response } from 'express';
import { config } from '../config';
const COOKIE_NAME = 'fp_refresh';
const REFRESH_COOKIE_NAME = 'fp_refresh';
const ACCESS_COOKIE_NAME = 'fp_access';
const isProduction = config.nodeEnv === 'production';
// ===================================
// Refresh Token Cookie (30 days, /api/auth path)
// ===================================
function getRefreshCookieOptions(): CookieOptions {
return {
httpOnly: true,
@@ -15,11 +20,11 @@ function getRefreshCookieOptions(): CookieOptions {
}
export function setRefreshTokenCookie(res: Response, refreshToken: string): void {
res.cookie(COOKIE_NAME, refreshToken, getRefreshCookieOptions());
res.cookie(REFRESH_COOKIE_NAME, refreshToken, getRefreshCookieOptions());
}
export function clearRefreshTokenCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, {
res.clearCookie(REFRESH_COOKIE_NAME, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
@@ -28,5 +33,36 @@ export function clearRefreshTokenCookie(res: Response): void {
}
export function getRefreshTokenFromCookie(cookies: Record<string, string>): string | undefined {
return cookies?.[COOKIE_NAME];
return cookies?.[REFRESH_COOKIE_NAME];
}
// ===================================
// Access Token Cookie (1 hour, /api path)
// ===================================
function getAccessCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/api',
maxAge: 60 * 60 * 1000, // 1 hour in ms
};
}
export function setAccessTokenCookie(res: Response, accessToken: string): void {
res.cookie(ACCESS_COOKIE_NAME, accessToken, getAccessCookieOptions());
}
export function clearAccessTokenCookie(res: Response): void {
res.clearCookie(ACCESS_COOKIE_NAME, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/api',
});
}
export function getAccessTokenFromCookie(cookies: Record<string, string>): string | undefined {
return cookies?.[ACCESS_COOKIE_NAME];
}
@@ -3,7 +3,7 @@ import { screen, render } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ProtectedRoute } from './ProtectedRoute';
import { useAuthStore } from '@/store/auth';
import type { User, AuthTokens } from '@/types/auth';
import type { User } from '@/types/auth';
// Mock hooks
vi.mock('@/hooks/useFeatureFlags', () => ({
@@ -59,12 +59,7 @@ describe('ProtectedRoute', () => {
permissions: ['games.play'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
render(
<MemoryRouter initialEntries={['/protected']}>
@@ -120,12 +115,7 @@ describe('ProtectedRoute', () => {
permissions: ['games.play', 'playlists.create'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
});
it('should allow access with correct permission', () => {
@@ -250,12 +240,7 @@ describe('ProtectedRoute', () => {
permissions: ['users.manage'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
render(
<MemoryRouter initialEntries={['/admin']}>
@@ -285,12 +270,7 @@ describe('ProtectedRoute', () => {
permissions: ['games.play'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
render(
<MemoryRouter initialEntries={['/admin']}>
@@ -323,12 +303,7 @@ describe('ProtectedRoute', () => {
permissions: ['playlists.create'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
render(
<MemoryRouter initialEntries={['/playlists']}>
@@ -358,12 +333,7 @@ describe('ProtectedRoute', () => {
permissions: ['statistics.view'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
render(
<MemoryRouter initialEntries={['/statistics']}>
+3 -11
View File
@@ -1,24 +1,16 @@
import axios from 'axios';
import { toast } from 'sonner';
import { useAuthStore } from '@/store/auth';
import { logger } from '@/lib/logger';
// Create a separate axios instance for error reporting without interceptors
// This prevents infinite loops when error reporting itself fails
// This prevents infinite loops when error reporting itself fails.
// Auth is handled via HTTP-only cookies (withCredentials: true).
const errorReportingApi = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to error reporting requests
errorReportingApi.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
withCredentials: true,
});
const ERROR_QUEUE_KEY = 'flashpoint-error-queue';
+42 -1
View File
@@ -1,6 +1,11 @@
import { ReactNode } from 'react';
import { ReactNode, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Shuffle, Loader2 } from 'lucide-react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { gamesApi } from '@/lib/api/games';
import { useUIStore } from '@/store/ui';
interface AppShellProps {
@@ -8,7 +13,23 @@ interface AppShellProps {
}
export function AppShell({ children }: AppShellProps) {
const navigate = useNavigate();
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
const [isNavigating, setIsNavigating] = useState(false);
const handleFeelingLucky = async () => {
setIsNavigating(true);
try {
const game = await gamesApi.getRandom();
navigate(`/games/${game.id}`, {
state: { breadcrumbContext: { label: 'Home', href: '/' } },
});
} catch {
// silently fail
} finally {
setIsNavigating(false);
}
};
return (
<div className="flex flex-col h-screen bg-background text-foreground">
@@ -35,6 +56,26 @@ export function AppShell({ children }: AppShellProps) {
{children}
</main>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={handleFeelingLucky}
disabled={isNavigating}
className="fixed bottom-6 right-6 z-50 h-12 w-12 rounded-full shadow-lg hover:shadow-xl transition-shadow"
aria-label="I'm Feeling Lucky — open a random game"
>
{isNavigating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Shuffle className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left">I'm Feeling Lucky</TooltipContent>
</Tooltip>
</div>
);
}
@@ -7,7 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { GameCard } from './GameCard';
import { useAuthStore } from '@/store/auth';
import type { Game } from '@/types/game';
import type { User, AuthTokens } from '@/types/auth';
import type { User } from '@/types/auth';
import { toast } from 'sonner';
// Mock dependencies
@@ -265,11 +265,7 @@ describe('GameCard', () => {
role: 'user',
permissions: ['playlists.create'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
renderGameCard(mockFlashGame, {
showAddToPlaylistButton: true,
+10 -40
View File
@@ -24,14 +24,7 @@ interface AuthProviderProps {
}
export function AuthProvider({ children }: AuthProviderProps) {
const {
setAuth,
setGuestMode,
clearAuth,
isAuthenticated,
updateAccessToken,
setMaintenanceMode,
} = useAuthStore();
const { setAuth, setGuestMode, clearAuth, isAuthenticated, setMaintenanceMode } = useAuthStore();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -81,7 +74,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
},
});
setAuth(result.user, result.tokens);
// Access token is set as HTTP-only cookie by the backend
setAuth(result.user);
// Check maintenance mode after login
const isMaintenanceActive = await checkMaintenanceMode();
@@ -119,7 +113,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
async (userData: RegisterData) => {
try {
const result = await authApi.register(userData);
setAuth(result.user, result.tokens);
// Access token is set as HTTP-only cookie by the backend
setAuth(result.user);
} catch (error) {
logger.error('Registration failed:', error);
throw error;
@@ -153,47 +148,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
/**
* Refresh access token function
* Calls /auth/refresh — both cookies (access + refresh) are updated automatically.
*/
const refreshAccessToken = useCallback(async () => {
try {
const tokens = await authApi.refreshToken();
updateAccessToken(tokens.accessToken);
await authApi.refreshToken();
} catch (error) {
logger.error('Token refresh failed:', error);
// The axios interceptor will handle logout and redirect
// Just clear auth here to ensure clean state
clearAuth();
throw error;
}
}, [updateAccessToken, clearAuth]);
/**
* On page reload: if authenticated but no access token (memory-only), refresh via cookie
*/
useEffect(() => {
const state = useAuthStore.getState();
if (state.isAuthenticated && !state.accessToken) {
refreshAccessToken()
.then(async () => {
// Refresh user data to ensure permissions are server-authoritative
try {
const user = await authApi.getMe();
useAuthStore.getState().updateUser(user);
} catch (error) {
logger.error('Failed to refresh user data:', error);
}
})
.catch(() => {
clearAuth();
navigate('/login');
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [clearAuth]);
/**
* Set up token refresh interval (refresh every 50 minutes)
* Access tokens expire in 1 hour, so we refresh 10 minutes before expiry
* Access tokens expire in 1 hour, so we refresh 10 minutes before expiry.
* The refresh response updates both cookies automatically.
*/
useEffect(() => {
if (!isAuthenticated) return;
+3 -6
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { logger } from '@/lib/logger';
import { gamesApi } from '@/lib/api';
import { useAuthStore } from '@/store/auth';
export interface DownloadProgress {
percent: number;
@@ -37,15 +36,13 @@ export function useDownload(gameId: string) {
// Connect to SSE endpoint for progress updates with authentication
// NOTE: Raw fetch() is used here instead of apiClient because axios does not support
// SSE/ReadableStream. The token is manually attached from the auth store.
// If the token expires during a long download, the SSE connection will need to be
// re-established. See CLAUDE.md "Frontend API Guidelines" for the general policy.
// SSE/ReadableStream. Auth is handled via HTTP-only cookies (credentials: 'include').
// See CLAUDE.md "Frontend API Guidelines" for the general policy.
const abortController = new AbortController();
abortControllerRef.current = abortController;
const token = useAuthStore.getState().accessToken;
const response = await fetch(`/api/games/${gameId}/download/progress`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'include',
signal: abortController.signal,
});
+3 -4
View File
@@ -40,11 +40,10 @@ export const authApi = {
/**
* Refresh access token using refresh token
* Both tokens are updated via HTTP-only cookies automatically.
*/
refreshToken: async (): Promise<{ accessToken: string; expiresIn: number }> => {
const { data } = await apiClient.post<{ accessToken: string; expiresIn: number }>(
'/auth/refresh'
);
refreshToken: async (): Promise<{ expiresIn: number }> => {
const { data } = await apiClient.post<{ expiresIn: number }>('/auth/refresh');
return data;
},
+27 -40
View File
@@ -9,7 +9,7 @@ import { useSharedAccessStore } from '@/store/sharedAccess';
* All API modules should use this instance to ensure consistent:
* - Base URL configuration
* - Request/response interceptors
* - Authentication handling
* - Authentication handling (via HTTP-only cookies)
* - Error handling
*/
export const apiClient = axios.create({
@@ -17,7 +17,7 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
withCredentials: true, // Send HTTP-only cookies with every request
});
// ===================================
@@ -25,23 +25,18 @@ export const apiClient = axios.create({
// ===================================
/**
* Adds authentication token to all outgoing requests
* Priority: 1) Regular JWT Bearer token, 2) Shared access token
* Adds SharedAccess token header for unauthenticated shared playlist access.
* Regular auth uses HTTP-only cookies (sent automatically via withCredentials).
*/
apiClient.interceptors.request.use(
(config) => {
// First, try regular JWT token
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
return config;
}
// Second, try shared access token
const sharedToken = useSharedAccessStore.getState().getToken();
if (sharedToken) {
config.headers.Authorization = `SharedAccess ${sharedToken}`;
return config;
// Only set SharedAccess header for unauthenticated users on shared playlists.
// Authenticated users rely on the HTTP-only access token cookie.
if (!useAuthStore.getState().isAuthenticated) {
const sharedToken = useSharedAccessStore.getState().getToken();
if (sharedToken) {
config.headers.Authorization = `SharedAccess ${sharedToken}`;
}
}
return config;
@@ -57,18 +52,19 @@ apiClient.interceptors.request.use(
/**
* Token refresh queue to prevent race conditions when multiple
* requests receive 401 errors simultaneously
* requests receive 401 errors simultaneously.
* With HTTP-only cookies, subscribers just need to know refresh succeeded
* — the new cookie is set automatically by the refresh response.
*/
let isRefreshing = false;
let refreshSubscribers: { resolve: (token: string) => void; reject: (error: unknown) => void }[] =
[];
let refreshSubscribers: { resolve: () => void; reject: (error: unknown) => void }[] = [];
function subscribeTokenRefresh(resolve: (token: string) => void, reject: (error: unknown) => void) {
function subscribeTokenRefresh(resolve: () => void, reject: (error: unknown) => void) {
refreshSubscribers.push({ resolve, reject });
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach((sub) => sub.resolve(token));
function onTokenRefreshed() {
refreshSubscribers.forEach((sub) => sub.resolve());
refreshSubscribers = [];
}
@@ -85,7 +81,7 @@ function onTokenRefreshFailed(error: unknown) {
* - 404 error notifications
* - 500+ server error notifications
* - Automatic token refresh on 401 errors with queue support
* - Retry failed requests after token refresh
* - Retry failed requests after token refresh (cookie updated automatically)
* - Logout on refresh failure
* - Prevents duplicate refresh requests via isRefreshing flag
*/
@@ -123,10 +119,7 @@ apiClient.interceptors.response.use(
const errorCode = error.response.data?.code;
// Handle shared access token errors differently
if (
errorCode === 'SHARED_ACCESS_INVALID' ||
originalRequest.headers?.Authorization?.startsWith('SharedAccess ')
) {
if (errorCode === 'SHARED_ACCESS_INVALID') {
useSharedAccessStore.getState().clearToken();
toast.error('Your shared access has expired. Return to the playlist to continue browsing.');
// Don't redirect to login for shared access - let the UI handle it
@@ -144,8 +137,8 @@ apiClient.interceptors.response.use(
// If a token refresh is already in progress, wait for it
if (isRefreshing) {
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
subscribeTokenRefresh(() => {
// Cookie was updated by the refresh response — just retry
resolve(apiClient(originalRequest));
}, reject);
});
@@ -162,19 +155,13 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
// Try to refresh the token (cookie sent automatically)
const { data } = await apiClient.post<{ accessToken: string; expiresIn: number }>(
'/auth/refresh'
);
// Refresh the token — cookies sent/updated automatically
await apiClient.post('/auth/refresh');
// Update access token in the store
useAuthStore.getState().updateAccessToken(data.accessToken);
// Notify all waiting requests that refresh succeeded
onTokenRefreshed();
// Notify all waiting requests of the new token
onTokenRefreshed(data.accessToken);
// Update the authorization header and retry the original request
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
// Retry the original request — new cookie sent automatically
return apiClient(originalRequest);
} catch (refreshError) {
// Notify all waiting requests of the error
+8 -70
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useAuthStore } from './auth';
import type { User, AuthTokens } from '@/types/auth';
import type { User } from '@/types/auth';
describe('Auth Store', () => {
// Clear store and storage before each test
@@ -21,7 +21,6 @@ describe('Auth Store', () => {
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isGuest).toBe(false);
expect(state.isMaintenanceMode).toBe(false);
@@ -29,7 +28,7 @@ describe('Auth Store', () => {
});
describe('setAuth', () => {
it('should set authenticated user and tokens', () => {
it('should set authenticated user', () => {
const mockUser: User = {
id: 1,
username: 'testuser',
@@ -38,16 +37,10 @@ describe('Auth Store', () => {
permissions: ['games.play', 'playlists.create'],
};
const mockTokens: AuthTokens = {
accessToken: 'access-token-123',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
const state = useAuthStore.getState();
expect(state.user).toEqual(mockUser);
expect(state.accessToken).toBe(mockTokens.accessToken);
expect(state.isAuthenticated).toBe(true);
expect(state.isGuest).toBe(false);
});
@@ -61,12 +54,7 @@ describe('Auth Store', () => {
permissions: ['games.play'],
};
const mockTokens: AuthTokens = {
accessToken: 'access-token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
// Check that localStorage was updated
const stored = localStorage.getItem('flashpoint-auth');
@@ -88,7 +76,6 @@ describe('Auth Store', () => {
expect(state.user).not.toBeNull();
expect(state.user?.role).toBe('guest');
expect(state.user?.username).toBe('Guest');
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isGuest).toBe(true);
});
@@ -99,7 +86,6 @@ describe('Auth Store', () => {
const state = useAuthStore.getState();
expect(state.user?.permissions).toContain('games.read');
expect(state.user?.permissions).toContain('playlists.read');
expect(state.user?.permissions).toContain('games.play');
});
it('should persist guest session to sessionStorage', () => {
@@ -127,19 +113,13 @@ describe('Auth Store', () => {
permissions: ['games.play'],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
// Now clear
useAuthStore.getState().clearAuth();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isGuest).toBe(false);
});
@@ -153,12 +133,7 @@ describe('Auth Store', () => {
permissions: [],
};
const mockTokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, mockTokens);
useAuthStore.getState().setAuth(mockUser);
expect(localStorage.getItem('flashpoint-auth')).not.toBeNull();
useAuthStore.getState().clearAuth();
@@ -172,32 +147,6 @@ describe('Auth Store', () => {
});
});
describe('updateAccessToken', () => {
it('should update only the access token', () => {
const mockUser: User = {
id: 1,
username: 'testuser',
email: 'test@example.com',
role: 'user',
permissions: [],
};
const initialTokens: AuthTokens = {
accessToken: 'old-token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, initialTokens);
const newAccessToken = 'new-access-token';
useAuthStore.getState().updateAccessToken(newAccessToken);
const state = useAuthStore.getState();
expect(state.accessToken).toBe(newAccessToken);
expect(state.user).toEqual(mockUser); // Unchanged
});
});
describe('updateUser', () => {
it('should update user information', () => {
const initialUser: User = {
@@ -208,12 +157,7 @@ describe('Auth Store', () => {
permissions: ['games.play'],
};
const tokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(initialUser, tokens);
useAuthStore.getState().setAuth(initialUser);
const updatedUser: User = {
...initialUser,
@@ -225,7 +169,6 @@ describe('Auth Store', () => {
const state = useAuthStore.getState();
expect(state.user).toEqual(updatedUser);
expect(state.accessToken).toBe(tokens.accessToken); // Unchanged
});
});
@@ -251,12 +194,7 @@ describe('Auth Store', () => {
permissions: ['games.play', 'playlists.create', 'playlists.update'],
};
const tokens: AuthTokens = {
accessToken: 'token',
expiresIn: 3600,
};
useAuthStore.getState().setAuth(mockUser, tokens);
useAuthStore.getState().setAuth(mockUser);
});
describe('hasPermission', () => {
+3 -13
View File
@@ -1,21 +1,19 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { User, AuthTokens } from '../types/auth';
import { User } from '../types/auth';
import { useThemeStore } from './theme';
import { logger } from '@/lib/logger';
interface AuthState {
user: User | null;
accessToken: string | null;
isAuthenticated: boolean;
isGuest: boolean;
isMaintenanceMode: boolean;
// Actions
setAuth: (user: User, tokens: AuthTokens) => void;
setAuth: (user: User) => void;
setGuestMode: () => void;
clearAuth: () => void;
updateAccessToken: (token: string) => void;
updateUser: (user: User) => void;
setMaintenanceMode: (isActive: boolean) => void;
@@ -30,15 +28,13 @@ export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isGuest: false,
isMaintenanceMode: false,
setAuth: (user: User, tokens: AuthTokens) => {
setAuth: (user: User) => {
set({
user,
accessToken: tokens.accessToken,
isAuthenticated: true,
isGuest: false,
});
@@ -63,7 +59,6 @@ export const useAuthStore = create<AuthState>()(
};
set({
user: guestUser,
accessToken: null,
isAuthenticated: false,
isGuest: true,
});
@@ -72,16 +67,11 @@ export const useAuthStore = create<AuthState>()(
clearAuth: () => {
set({
user: null,
accessToken: null,
isAuthenticated: false,
isGuest: false,
});
},
updateAccessToken: (token: string) => {
set({ accessToken: token });
},
updateUser: (user: User) => {
set({ user });
},
-1
View File
@@ -31,7 +31,6 @@ export interface UserDetails {
// ===================================
export interface AuthTokens {
accessToken: string;
expiresIn: number;
}
+1 -1
View File
@@ -24,7 +24,7 @@
"backend": {
"name": "flashpoint-web-backend",
"version": "1.0.0",
"license": "MIT",
"license": "GPL-3.0",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.69.0",