Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ def database_type(self) -> str:
FRONTEND_URL: str = "http://localhost:3000"
BACKEND_URL: str = "http://localhost:8000"

# Cookie settings
COOKIE_SECURE: bool = False # Set True in production (HTTPS only)
COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
COOKIE_DOMAIN: str = "" # Empty = current domain only
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using httpOnly cookies with CORS, the COOKIE_DOMAIN setting can cause issues if not properly configured. If the domain is set to a parent domain (e.g., ".example.com"), but the frontend is on a subdomain, this might lead to cookie isolation issues. Consider adding validation or documentation to ensure COOKIE_DOMAIN matches the deployment architecture. Additionally, when COOKIE_DOMAIN is empty string, it's converted to None which is correct, but this behavior should be explicitly documented in the configuration comments.

Suggested change
COOKIE_DOMAIN: str = "" # Empty = current domain only
COOKIE_DOMAIN: str = "" # Empty string is treated as None / no explicit domain (browser uses current host). When setting a value (e.g. ".example.com"), ensure it matches your frontend domain/CORS setup to avoid cookie isolation issues with httpOnly cookies.

Copilot uses AI. Check for mistakes.

# Mailgun Email
MAILGUN_API_KEY: str = ""
MAILGUN_DOMAIN: str = ""
Expand Down
27 changes: 24 additions & 3 deletions apps/api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,34 @@ def get_servers() -> list[dict[str, str]]:
"backend",
])

# CORS middleware
# CORS middleware - explicit origins required for credentials (httpOnly cookies)
def get_cors_origins() -> list[str]:
"""Build CORS origins from settings"""
origins = [settings.FRONTEND_URL]
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CORS configuration adds FRONTEND_URL unconditionally to the allowed origins, even if it's empty or set to a default value. If settings.FRONTEND_URL is empty or invalid, this could cause CORS issues. Consider adding a check to ensure FRONTEND_URL is properly configured before adding it to the origins list, or add a fallback validation.

Suggested change
origins = [settings.FRONTEND_URL]
origins: list[str] = []
frontend_url = (settings.FRONTEND_URL or "").strip()
if frontend_url:
origins.append(frontend_url)

Copilot uses AI. Check for mistakes.
# Add backend URL for Swagger UI
if settings.BACKEND_URL and settings.BACKEND_URL != settings.FRONTEND_URL:
origins.append(settings.BACKEND_URL)
# Add common development origins
if settings.DEBUG:
origins.extend([
"http://localhost:3000",
"http://localhost:8000",
"http://localhost:8081",
"http://127.0.0.1:3000",
"http://127.0.0.1:8000",
"http://127.0.0.1:8081",
])
# Remove duplicates while preserving order
return list(dict.fromkeys(origins))


app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_origins=get_cors_origins(),
allow_credentials=True, # Required for httpOnly cookies
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Set-Cookie"], # Allow browser to see Set-Cookie header
)


Expand Down
20 changes: 16 additions & 4 deletions apps/api/src/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,25 @@ async def get_current_user(
bearer_token: Optional[HTTPAuthorizationCredentials] = Security(bearer_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
"""Get current authenticated user from JWT token or API key"""
# Get token from either OAuth2 or Bearer header
"""Get current authenticated user from JWT token (cookie or header) or API key"""
# Priority order: httpOnly cookie > Bearer header > OAuth2 token
token = None
if bearer_token:

# 1. Try httpOnly cookie first (most secure for browser clients)
cookie_token = request.cookies.get("access_token")
if cookie_token:
token = cookie_token
logger.debug("[AUTH] Token from httpOnly cookie")

# 2. Fallback to Bearer header (for API clients)
if not token and bearer_token:
token = bearer_token.credentials
elif oauth_token:
logger.debug("[AUTH] Token from Bearer header")

# 3. Fallback to OAuth2 token
if not token and oauth_token:
token = oauth_token
logger.debug("[AUTH] Token from OAuth2 scheme")

if not token:
logger.warning("[AUTH] No token provided")
Expand Down
43 changes: 37 additions & 6 deletions apps/api/src/routers/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Optional

import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Form, Request, Body, Query
from fastapi import APIRouter, Depends, HTTPException, status, Form, Request, Body, Query, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
Expand All @@ -18,7 +18,6 @@
UserLogin,
UserResponse,
ClientCredentials,
TokenResponse,
APIResponse,
ErrorDetail,
)
Expand Down Expand Up @@ -148,8 +147,32 @@ async def signup(
)


def _set_auth_cookie(response: Response, token: str) -> None:
"""Set httpOnly cookie with JWT token"""
response.set_cookie(
key="access_token",
value=token,
httponly=True, # Prevents JavaScript access (XSS protection)
secure=settings.COOKIE_SECURE, # HTTPS only in production
samesite=settings.COOKIE_SAMESITE, # CSRF protection
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie's max_age is set to ACCESS_TOKEN_EXPIRE_MINUTES * 60, which equals 43200 * 60 = 2,592,000 seconds (30 days). However, the comment in Settings.tsx (line 598) states "Durée de validité : 30 jours" (30 days validity), which is correct. Consider adding a comment here that explicitly states this converts to 30 days for clarity, as the calculation is not immediately obvious.

Suggested change
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert minutes to seconds (e.g. 43200 min = 30 days)

Copilot uses AI. Check for mistakes.
path="/", # Available for all paths
domain=settings.COOKIE_DOMAIN or None, # None = current domain
)


def _clear_auth_cookie(response: Response) -> None:
"""Clear the auth cookie on logout"""
response.delete_cookie(
key="access_token",
path="/",
domain=settings.COOKIE_DOMAIN or None,
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The delete_cookie method should include the same 'secure' and 'samesite' parameters that were used when setting the cookie. Cookie deletion can fail if these parameters don't match the original cookie's settings. Add 'secure=settings.COOKIE_SECURE' and 'samesite=settings.COOKIE_SAMESITE' to ensure reliable cookie deletion.

Suggested change
domain=settings.COOKIE_DOMAIN or None,
domain=settings.COOKIE_DOMAIN or None,
secure=settings.COOKIE_SECURE,
samesite=settings.COOKIE_SAMESITE,

Copilot uses AI. Check for mistakes.
)


@router.post("/login", response_model=APIResponse)
async def login(
response: Response,
credentials: UserLogin = Body(
...,
openapi_examples={
Expand All @@ -165,7 +188,7 @@ async def login(
),
db: AsyncSession = Depends(get_db)
) -> APIResponse:
"""Login and get access token"""
"""Login and get access token (stored in httpOnly cookie)"""
result = await db.execute(select(User).where(User.email == credentials.email))
user = result.scalar_one_or_none()

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

# Create access token
# Create access token and set as httpOnly cookie
access_token = create_access_token(data={"sub": user.id})
token_response = TokenResponse(access_token=access_token)
_set_auth_cookie(response, access_token)

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


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


@router.post("/logout", response_model=APIResponse)
async def logout(response: Response) -> APIResponse:
"""Logout and clear auth cookie"""
_clear_auth_cookie(response)
return APIResponse(success=True, data={"message": "Logout successful"})


@router.get("/credentials", response_model=APIResponse)
async def get_credentials(current_user: User = Depends(get_current_user)) -> APIResponse:
"""Get API credentials (client_id only, client_secret is never returned)"""
Expand Down
2 changes: 1 addition & 1 deletion apps/api/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 6 additions & 8 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,15 @@ class APIClient {
headers: {
'Content-Type': 'application/json',
},
// Enable credentials for httpOnly cookie authentication
withCredentials: true,
// Force HTTP adapter to prevent browser from upgrading to HTTPS
adapter: 'xhr',
})

// Request interceptor to add auth token and impersonation header
// Request interceptor for impersonation header and debugging
// Note: Auth is now handled via httpOnly cookie (withCredentials: true)
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}

// Add impersonation header if viewing another user's PDL (admin feature)
const { impersonation } = usePdlStore.getState()
if (impersonation?.ownerId) {
Expand All @@ -65,8 +63,8 @@ class APIClient {
(response) => response,
async (error: AxiosError<APIResponse>) => {
if (error.response?.status === 401) {
// Token expired, clear storage and redirect to login
localStorage.removeItem('access_token')
// Cookie expired/invalid, redirect to login
// The httpOnly cookie will be cleared by the server on next request
window.location.href = '/login'
}
return Promise.reject(error)
Expand Down
37 changes: 19 additions & 18 deletions apps/web/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@ import type { UserCreate, UserLogin } from '@/types/api'

export const useAuth = () => {
const queryClient = useQueryClient()
const { setUser, setToken, logout: logoutStore } = useAuthStore()
const { user: storedUser, setUser, setAuthenticated, logout: logoutStore } = useAuthStore()

const { data: user, isLoading } = useQuery({
const { data: user, isLoading, isError } = useQuery({
queryKey: ['user'],
queryFn: async () => {
const response = await authApi.getMe()
if (response.success && response.data) {
setUser(response.data)
setAuthenticated(true)
// Update debug mode based on user settings
const debugMode = response.data.debug_mode || false
setDebugMode(debugMode)
// Always log this to verify it's working (even when debug is off)
console.log('[useAuth] Debug mode set to:', debugMode, 'localStorage value:', JSON.stringify(localStorage.getItem('debug_mode')))
return response.data
}
// Not authenticated - clear state
setAuthenticated(false)
return null
},
enabled: !!localStorage.getItem('access_token'),
// Always try to fetch - the httpOnly cookie will be sent automatically
// If not authenticated, the request will fail with 401
retry: false,
// Refetch on window focus to check if session is still valid
refetchOnWindowFocus: true,
})

const loginMutation = useMutation({
Expand All @@ -34,11 +39,10 @@ export const useAuth = () => {
}
return response
},
onSuccess: (response) => {
if (response.success && response.data) {
setToken(response.data.access_token)
queryClient.invalidateQueries({ queryKey: ['user'] })
}
onSuccess: () => {
// Cookie is set by the server, just refresh user data
setAuthenticated(true)
queryClient.invalidateQueries({ queryKey: ['user'] })
},
})

Expand All @@ -52,20 +56,17 @@ export const useAuth = () => {
},
})

const logout = () => {
logoutStore()
const logout = async () => {
await logoutStore()
queryClient.clear()
}

// Check if we have a valid token in localStorage
const hasToken = !!localStorage.getItem('access_token')

return {
user,
isLoading,
// Consider authenticated if user exists OR if we have a token in localStorage
// This prevents logout during page refresh while the user query is loading
isAuthenticated: !!user || hasToken,
// Authenticated if we have user data, or if we have stored user and query is still loading
// isError means the cookie check failed (401), so not authenticated
isAuthenticated: !!user || (!!storedUser && isLoading && !isError),
login: loginMutation.mutate,
signup: signupMutation.mutate,
logout,
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/pages/AdminOffers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ const formatPrice = (price: number | string | undefined | null, decimals: number
export default function AdminOffers() {
const queryClient = useQueryClient()
const { hasAction } = usePermissions()
// Use direct token check to avoid timing issues with useAuth hook
const hasToken = !!localStorage.getItem('access_token')

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

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

Expand All @@ -124,7 +122,7 @@ export default function AdminOffers() {
}
return { sync_in_progress: false, provider: null, started_at: null, current_step: null, steps: [], progress: 0 }
},
enabled: hasToken,
// Query will use httpOnly cookie automatically via withCredentials
refetchInterval: (query) => {
// Poll much more frequently when sync is in progress or we're refreshing
if (refreshingProvider || loadingPreview || query.state.data?.sync_in_progress) {
Expand Down
17 changes: 4 additions & 13 deletions apps/web/src/pages/ApiDocs.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwaggerUI from 'swagger-ui-react'
import 'swagger-ui-react/swagger-ui.css'
import { Key } from 'lucide-react'
import { useEffect, useMemo } from 'react'
import { useEffect } from 'react'
import { useThemeStore } from '@/stores/themeStore'
import { Link } from 'react-router-dom'

Expand All @@ -16,8 +16,6 @@ declare global {
}

export default function ApiDocs() {
// Get the access token from localStorage
const accessToken = useMemo(() => localStorage.getItem('access_token'), [])
const { isDark } = useThemeStore()
// Use runtime config first, then build-time env, then default
const apiBaseUrl = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
Expand Down Expand Up @@ -892,18 +890,11 @@ export default function ApiDocs() {
displayRequestDuration={true}
deepLinking={false}
requestInterceptor={(req) => {
// Add Authorization header with the access token
if (accessToken) {
req.headers.Authorization = `Bearer ${accessToken}`
}
// Credentials (httpOnly cookie) will be sent automatically
// when withCredentials is enabled on fetch requests
req.credentials = 'include'
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requestInterceptor sets 'req.credentials' to 'include', but the correct property name for Swagger UI's request interceptor is 'req.withCredentials'. The 'credentials' property is used by the Fetch API, but Swagger UI uses XMLHttpRequest which requires 'withCredentials'. This should be changed to 'req.withCredentials = true' to ensure cookies are sent with API requests from the Swagger UI.

Suggested change
// when withCredentials is enabled on fetch requests
req.credentials = 'include'
// when withCredentials is enabled on XHR requests
req.withCredentials = true

Copilot uses AI. Check for mistakes.
return req
}}
onComplete={(swaggerApi) => {
// Pre-authorize with the Bearer token
if (accessToken) {
swaggerApi.preauthorizeApiKey('HTTPBearer', accessToken)
}
}}
/>
</div>
</div>
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/pages/ConsentRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,8 @@ export default function ConsentRedirect() {
if (state) backendUrl.searchParams.set('state', state)
if (usagePointId) backendUrl.searchParams.set('usage_point_id', usagePointId)

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

// Redirect to backend
window.location.href = backendUrl.toString()
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/pages/OAuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,8 @@ export default function OAuthCallback() {
if (state) backendUrl.searchParams.set('state', state)
if (usagePointId) backendUrl.searchParams.set('usage_point_id', usagePointId)

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

// Use replace to prevent browser back button from returning here
window.location.replace(backendUrl.toString())
Expand Down
Loading