Skip to content

Commit 842a943

Browse files
Clément VALENTINclaude
andcommitted
security: migrate JWT auth from localStorage to httpOnly cookies
Replace vulnerable localStorage token storage with secure httpOnly cookies to prevent XSS token theft attacks. Backend changes: - Add cookie configuration settings (secure, sameSite, domain) - Login sets httpOnly cookie instead of returning token in body - Add /accounts/logout endpoint to clear auth cookie - Auth middleware prioritizes cookie > Authorization header - CORS updated with explicit origins for credentials support Frontend changes: - Axios client uses withCredentials for automatic cookie handling - Remove all localStorage token references - Auth state checked via /accounts/me endpoint - Logout calls backend to clear cookie Helm chart: - Add config.cookie section with secure, sameSite, domain options - Backend deployment includes COOKIE_* environment variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent d1834a4 commit 842a943

File tree

15 files changed

+161
-115
lines changed

15 files changed

+161
-115
lines changed

apps/api/src/config/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ def database_type(self) -> str:
5252
FRONTEND_URL: str = "http://localhost:3000"
5353
BACKEND_URL: str = "http://localhost:8000"
5454

55+
# Cookie settings
56+
COOKIE_SECURE: bool = False # Set True in production (HTTPS only)
57+
COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
58+
COOKIE_DOMAIN: str = "" # Empty = current domain only
59+
5560
# Mailgun Email
5661
MAILGUN_API_KEY: str = ""
5762
MAILGUN_DOMAIN: str = ""

apps/api/src/main.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,34 @@ def get_servers() -> list[dict[str, str]]:
110110
"backend",
111111
])
112112

113-
# CORS middleware
113+
# CORS middleware - explicit origins required for credentials (httpOnly cookies)
114+
def get_cors_origins() -> list[str]:
115+
"""Build CORS origins from settings"""
116+
origins = [settings.FRONTEND_URL]
117+
# Add backend URL for Swagger UI
118+
if settings.BACKEND_URL and settings.BACKEND_URL != settings.FRONTEND_URL:
119+
origins.append(settings.BACKEND_URL)
120+
# Add common development origins
121+
if settings.DEBUG:
122+
origins.extend([
123+
"http://localhost:3000",
124+
"http://localhost:8000",
125+
"http://localhost:8081",
126+
"http://127.0.0.1:3000",
127+
"http://127.0.0.1:8000",
128+
"http://127.0.0.1:8081",
129+
])
130+
# Remove duplicates while preserving order
131+
return list(dict.fromkeys(origins))
132+
133+
114134
app.add_middleware(
115135
CORSMiddleware,
116-
allow_origins=["*"], # Configure appropriately for production
117-
allow_credentials=True,
136+
allow_origins=get_cors_origins(),
137+
allow_credentials=True, # Required for httpOnly cookies
118138
allow_methods=["*"],
119139
allow_headers=["*"],
140+
expose_headers=["Set-Cookie"], # Allow browser to see Set-Cookie header
120141
)
121142

122143

apps/api/src/middleware/auth.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,25 @@ async def get_current_user(
4444
bearer_token: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
4545
db: AsyncSession = Depends(get_db)
4646
) -> User:
47-
"""Get current authenticated user from JWT token or API key"""
48-
# Get token from either OAuth2 or Bearer header
47+
"""Get current authenticated user from JWT token (cookie or header) or API key"""
48+
# Priority order: httpOnly cookie > Bearer header > OAuth2 token
4949
token = None
50-
if bearer_token:
50+
51+
# 1. Try httpOnly cookie first (most secure for browser clients)
52+
cookie_token = request.cookies.get("access_token")
53+
if cookie_token:
54+
token = cookie_token
55+
logger.debug("[AUTH] Token from httpOnly cookie")
56+
57+
# 2. Fallback to Bearer header (for API clients)
58+
if not token and bearer_token:
5159
token = bearer_token.credentials
52-
elif oauth_token:
60+
logger.debug("[AUTH] Token from Bearer header")
61+
62+
# 3. Fallback to OAuth2 token
63+
if not token and oauth_token:
5364
token = oauth_token
65+
logger.debug("[AUTH] Token from OAuth2 scheme")
5466

5567
if not token:
5668
logger.warning("[AUTH] No token provided")

apps/api/src/routers/accounts.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Optional
55

66
import httpx
7-
from fastapi import APIRouter, Depends, HTTPException, status, Form, Request, Body, Query
7+
from fastapi import APIRouter, Depends, HTTPException, status, Form, Request, Body, Query, Response
88
from sqlalchemy import select
99
from sqlalchemy.ext.asyncio import AsyncSession
1010
from sqlalchemy.orm import selectinload
@@ -18,7 +18,6 @@
1818
UserLogin,
1919
UserResponse,
2020
ClientCredentials,
21-
TokenResponse,
2221
APIResponse,
2322
ErrorDetail,
2423
)
@@ -148,8 +147,32 @@ async def signup(
148147
)
149148

150149

150+
def _set_auth_cookie(response: Response, token: str) -> None:
151+
"""Set httpOnly cookie with JWT token"""
152+
response.set_cookie(
153+
key="access_token",
154+
value=token,
155+
httponly=True, # Prevents JavaScript access (XSS protection)
156+
secure=settings.COOKIE_SECURE, # HTTPS only in production
157+
samesite=settings.COOKIE_SAMESITE, # CSRF protection
158+
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
159+
path="/", # Available for all paths
160+
domain=settings.COOKIE_DOMAIN or None, # None = current domain
161+
)
162+
163+
164+
def _clear_auth_cookie(response: Response) -> None:
165+
"""Clear the auth cookie on logout"""
166+
response.delete_cookie(
167+
key="access_token",
168+
path="/",
169+
domain=settings.COOKIE_DOMAIN or None,
170+
)
171+
172+
151173
@router.post("/login", response_model=APIResponse)
152174
async def login(
175+
response: Response,
153176
credentials: UserLogin = Body(
154177
...,
155178
openapi_examples={
@@ -165,7 +188,7 @@ async def login(
165188
),
166189
db: AsyncSession = Depends(get_db)
167190
) -> APIResponse:
168-
"""Login and get access token"""
191+
"""Login and get access token (stored in httpOnly cookie)"""
169192
result = await db.execute(select(User).where(User.email == credentials.email))
170193
user = result.scalar_one_or_none()
171194

@@ -175,11 +198,12 @@ async def login(
175198
if not user.is_active:
176199
return APIResponse(success=False, error=ErrorDetail(code="USER_INACTIVE", message="User account is inactive"))
177200

178-
# Create access token
201+
# Create access token and set as httpOnly cookie
179202
access_token = create_access_token(data={"sub": user.id})
180-
token_response = TokenResponse(access_token=access_token)
203+
_set_auth_cookie(response, access_token)
181204

182-
return APIResponse(success=True, data=token_response.model_dump())
205+
# Return success without exposing token in response body
206+
return APIResponse(success=True, data={"message": "Login successful"})
183207

184208

185209
@router.post("/token", tags=["Authentication"])
@@ -312,6 +336,13 @@ async def get_current_user_info(
312336
return APIResponse(success=True, data=user_data)
313337

314338

339+
@router.post("/logout", response_model=APIResponse)
340+
async def logout(response: Response) -> APIResponse:
341+
"""Logout and clear auth cookie"""
342+
_clear_auth_cookie(response)
343+
return APIResponse(success=True, data={"message": "Logout successful"})
344+
345+
315346
@router.get("/credentials", response_model=APIResponse)
316347
async def get_credentials(current_user: User = Depends(get_current_user)) -> APIResponse:
317348
"""Get API credentials (client_id only, client_secret is never returned)"""

apps/api/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/web/src/api/client.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,15 @@ class APIClient {
3333
headers: {
3434
'Content-Type': 'application/json',
3535
},
36+
// Enable credentials for httpOnly cookie authentication
37+
withCredentials: true,
3638
// Force HTTP adapter to prevent browser from upgrading to HTTPS
3739
adapter: 'xhr',
3840
})
3941

40-
// Request interceptor to add auth token and impersonation header
42+
// Request interceptor for impersonation header and debugging
43+
// Note: Auth is now handled via httpOnly cookie (withCredentials: true)
4144
this.client.interceptors.request.use((config) => {
42-
const token = localStorage.getItem('access_token')
43-
if (token) {
44-
config.headers.Authorization = `Bearer ${token}`
45-
}
46-
4745
// Add impersonation header if viewing another user's PDL (admin feature)
4846
const { impersonation } = usePdlStore.getState()
4947
if (impersonation?.ownerId) {
@@ -65,8 +63,8 @@ class APIClient {
6563
(response) => response,
6664
async (error: AxiosError<APIResponse>) => {
6765
if (error.response?.status === 401) {
68-
// Token expired, clear storage and redirect to login
69-
localStorage.removeItem('access_token')
66+
// Cookie expired/invalid, redirect to login
67+
// The httpOnly cookie will be cleared by the server on next request
7068
window.location.href = '/login'
7169
}
7270
return Promise.reject(error)

apps/web/src/hooks/useAuth.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,29 @@ import type { UserCreate, UserLogin } from '@/types/api'
66

77
export const useAuth = () => {
88
const queryClient = useQueryClient()
9-
const { setUser, setToken, logout: logoutStore } = useAuthStore()
9+
const { user: storedUser, setUser, setAuthenticated, logout: logoutStore } = useAuthStore()
1010

11-
const { data: user, isLoading } = useQuery({
11+
const { data: user, isLoading, isError } = useQuery({
1212
queryKey: ['user'],
1313
queryFn: async () => {
1414
const response = await authApi.getMe()
1515
if (response.success && response.data) {
1616
setUser(response.data)
17+
setAuthenticated(true)
1718
// Update debug mode based on user settings
1819
const debugMode = response.data.debug_mode || false
1920
setDebugMode(debugMode)
20-
// Always log this to verify it's working (even when debug is off)
21-
console.log('[useAuth] Debug mode set to:', debugMode, 'localStorage value:', JSON.stringify(localStorage.getItem('debug_mode')))
2221
return response.data
2322
}
23+
// Not authenticated - clear state
24+
setAuthenticated(false)
2425
return null
2526
},
26-
enabled: !!localStorage.getItem('access_token'),
27+
// Always try to fetch - the httpOnly cookie will be sent automatically
28+
// If not authenticated, the request will fail with 401
29+
retry: false,
30+
// Refetch on window focus to check if session is still valid
31+
refetchOnWindowFocus: true,
2732
})
2833

2934
const loginMutation = useMutation({
@@ -34,11 +39,10 @@ export const useAuth = () => {
3439
}
3540
return response
3641
},
37-
onSuccess: (response) => {
38-
if (response.success && response.data) {
39-
setToken(response.data.access_token)
40-
queryClient.invalidateQueries({ queryKey: ['user'] })
41-
}
42+
onSuccess: () => {
43+
// Cookie is set by the server, just refresh user data
44+
setAuthenticated(true)
45+
queryClient.invalidateQueries({ queryKey: ['user'] })
4246
},
4347
})
4448

@@ -52,20 +56,17 @@ export const useAuth = () => {
5256
},
5357
})
5458

55-
const logout = () => {
56-
logoutStore()
59+
const logout = async () => {
60+
await logoutStore()
5761
queryClient.clear()
5862
}
5963

60-
// Check if we have a valid token in localStorage
61-
const hasToken = !!localStorage.getItem('access_token')
62-
6364
return {
6465
user,
6566
isLoading,
66-
// Consider authenticated if user exists OR if we have a token in localStorage
67-
// This prevents logout during page refresh while the user query is loading
68-
isAuthenticated: !!user || hasToken,
67+
// Authenticated if we have user data, or if we have stored user and query is still loading
68+
// isError means the cookie check failed (401), so not authenticated
69+
isAuthenticated: !!user || (!!storedUser && isLoading && !isError),
6970
login: loginMutation.mutate,
7071
signup: signupMutation.mutate,
7172
logout,

apps/web/src/pages/AdminOffers.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ const formatPrice = (price: number | string | undefined | null, decimals: number
1919
export default function AdminOffers() {
2020
const queryClient = useQueryClient()
2121
const { hasAction } = usePermissions()
22-
// Use direct token check to avoid timing issues with useAuth hook
23-
const hasToken = !!localStorage.getItem('access_token')
2422

2523
// Filters
2624
const [filterProvider, setFilterProvider] = useState<string>('all')
@@ -96,7 +94,7 @@ export default function AdminOffers() {
9694
}
9795
return []
9896
},
99-
enabled: hasToken,
97+
// Query will use httpOnly cookie automatically via withCredentials
10098
staleTime: 0, // Always refetch on mount to avoid stale cache issues
10199
})
102100

@@ -110,7 +108,7 @@ export default function AdminOffers() {
110108
}
111109
return []
112110
},
113-
enabled: hasToken,
111+
// Query will use httpOnly cookie automatically via withCredentials
114112
staleTime: 0, // Always refetch on mount to avoid stale cache issues
115113
})
116114

@@ -124,7 +122,7 @@ export default function AdminOffers() {
124122
}
125123
return { sync_in_progress: false, provider: null, started_at: null, current_step: null, steps: [], progress: 0 }
126124
},
127-
enabled: hasToken,
125+
// Query will use httpOnly cookie automatically via withCredentials
128126
refetchInterval: (query) => {
129127
// Poll much more frequently when sync is in progress or we're refreshing
130128
if (refreshingProvider || loadingPreview || query.state.data?.sync_in_progress) {

apps/web/src/pages/ApiDocs.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import SwaggerUI from 'swagger-ui-react'
22
import 'swagger-ui-react/swagger-ui.css'
33
import { Key } from 'lucide-react'
4-
import { useEffect, useMemo } from 'react'
4+
import { useEffect } from 'react'
55
import { useThemeStore } from '@/stores/themeStore'
66
import { Link } from 'react-router-dom'
77

@@ -16,8 +16,6 @@ declare global {
1616
}
1717

1818
export default function ApiDocs() {
19-
// Get the access token from localStorage
20-
const accessToken = useMemo(() => localStorage.getItem('access_token'), [])
2119
const { isDark } = useThemeStore()
2220
// Use runtime config first, then build-time env, then default
2321
const apiBaseUrl = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
@@ -892,18 +890,11 @@ export default function ApiDocs() {
892890
displayRequestDuration={true}
893891
deepLinking={false}
894892
requestInterceptor={(req) => {
895-
// Add Authorization header with the access token
896-
if (accessToken) {
897-
req.headers.Authorization = `Bearer ${accessToken}`
898-
}
893+
// Credentials (httpOnly cookie) will be sent automatically
894+
// when withCredentials is enabled on fetch requests
895+
req.credentials = 'include'
899896
return req
900897
}}
901-
onComplete={(swaggerApi) => {
902-
// Pre-authorize with the Bearer token
903-
if (accessToken) {
904-
swaggerApi.preauthorizeApiKey('HTTPBearer', accessToken)
905-
}
906-
}}
907898
/>
908899
</div>
909900
</div>

apps/web/src/pages/ConsentRedirect.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,8 @@ export default function ConsentRedirect() {
3838
if (state) backendUrl.searchParams.set('state', state)
3939
if (usagePointId) backendUrl.searchParams.set('usage_point_id', usagePointId)
4040

41-
// Include access_token for backend to identify the user
42-
const accessToken = localStorage.getItem('access_token')
43-
if (accessToken) {
44-
backendUrl.searchParams.set('access_token', accessToken)
45-
}
41+
// Note: The httpOnly cookie will be sent automatically with the redirect
42+
// No need to pass access_token in URL (more secure)
4643

4744
// Redirect to backend
4845
window.location.href = backendUrl.toString()

0 commit comments

Comments
 (0)