refactor: move access token to HTTP-only cookie
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface UserDetails {
|
||||
// ===================================
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user