diff --git a/backend/src/config.py b/backend/src/config.py index 36be2b9..51e960d 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -18,7 +18,7 @@ class Settings(BaseSettings): cookie_secure: bool = Field(default=False, env="COOKIE_SECURE") cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE") cookie_domain: str | None = Field(default=None, env="COOKIE_DOMAIN") - cookie_max_age: int = Field(default=3600, env="COOKIE_MAX_AGE") + cookie_max_age: int = Field(default=28800, env="COOKIE_MAX_AGE") # 8 hours cookie_name: str = Field(default="access_token", env="COOKIE_NAME") # Database settings diff --git a/backend/src/controller.py b/backend/src/controller.py index 6267c79..6286f4c 100644 --- a/backend/src/controller.py +++ b/backend/src/controller.py @@ -6,6 +6,9 @@ from src.models import TokenResponse, UserInfo from src.service import AuthService, UserService from src.config import get_settings from src.database import get_db +import logging + +logger = logging.getLogger(__name__) # Initialize HTTPBearer security dependency bearer_scheme = HTTPBearer() @@ -13,6 +16,9 @@ bearer_scheme = HTTPBearer() # Get settings settings = get_settings() +# Cookie name for refresh token +REFRESH_TOKEN_COOKIE = "refresh_token" + class AuthController: """ @@ -85,6 +91,20 @@ class AuthController: path="/", ) + # Also store the refresh token if available + refresh_token = token_response.get("refresh_token") + if refresh_token: + response.set_cookie( + key=REFRESH_TOKEN_COOKIE, + value=refresh_token, + httponly=True, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + max_age=86400 * 7, # 7 days for refresh token + domain=settings.cookie_domain, + path="/", + ) + return response @staticmethod @@ -148,15 +168,100 @@ class AuthController: status_code=status.HTTP_200_OK ) - # Clear the authentication cookie + # Clear the authentication cookies response.delete_cookie( key=settings.cookie_name, path="/", domain=settings.cookie_domain, ) + response.delete_cookie( + key=REFRESH_TOKEN_COOKIE, + path="/", + domain=settings.cookie_domain, + ) return response + @staticmethod + def refresh_token(request: Request) -> JSONResponse: + """ + Silently refresh the access token using the refresh token. + This endpoint is called by the frontend before the access token expires. + + Args: + request (Request): The FastAPI request object. + + Returns: + JSONResponse: Contains success status and new expiration time. + """ + # Get the refresh token from cookies + refresh_token = request.cookies.get(REFRESH_TOKEN_COOKIE) + + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No refresh token available", + ) + + try: + # Get new tokens from Keycloak + token_response = AuthService.refresh_access_token(refresh_token) + new_access_token = token_response.get("access_token") + new_refresh_token = token_response.get("refresh_token") + expires_in = token_response.get("expires_in", settings.cookie_max_age) + + if not new_access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Failed to refresh token", + ) + + # Create response with new tokens + response = JSONResponse( + content={ + "success": True, + "expires_in": expires_in, + }, + status_code=status.HTTP_200_OK + ) + + # Set new access token cookie + response.set_cookie( + key=settings.cookie_name, + value=new_access_token, + httponly=True, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + max_age=expires_in, + domain=settings.cookie_domain, + path="/", + ) + + # Set new refresh token cookie if provided + if new_refresh_token: + response.set_cookie( + key=REFRESH_TOKEN_COOKIE, + value=new_refresh_token, + httponly=True, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + max_age=86400 * 7, # 7 days + domain=settings.cookie_domain, + path="/", + ) + + logger.info("Token refreshed successfully") + return response + + except HTTPException: + raise + except Exception as exc: + logger.error(f"Token refresh failed: {exc}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token refresh failed", + ) from exc + @staticmethod def protected_endpoint( credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), diff --git a/backend/src/main.py b/backend/src/main.py index 94c2d6e..3b986a6 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -196,6 +196,19 @@ async def logout(request: Request): return AuthController.logout() +# Define the token refresh endpoint +@app.post("/api/auth/refresh") +async def refresh_token(request: Request): + """ + Silently refresh the access token using the refresh token cookie. + This should be called by the frontend before the access token expires. + + Returns: + dict: Success status and new expiration time. + """ + return AuthController.refresh_token(request) + + # Define the protected endpoint (kept for API token-based access) @app.get("/api/protected", response_model=UserInfo) async def protected_endpoint( diff --git a/backend/src/service.py b/backend/src/service.py index 7c999ef..8710e4f 100644 --- a/backend/src/service.py +++ b/backend/src/service.py @@ -115,6 +115,35 @@ class AuthService: detail="Could not decode token", ) from exc + @staticmethod + def refresh_access_token(refresh_token: str) -> dict: + """ + Use a refresh token to get a new access token. + + Args: + refresh_token: The refresh token from Keycloak + + Returns: + dict containing new access_token, refresh_token, and expires_in + """ + try: + keycloak_openid = get_keycloak_openid() + token = keycloak_openid.refresh_token(refresh_token) + logger.info("Token refresh successful") + return token + except KeycloakAuthenticationError as exc: + logger.error(f"Token refresh failed - KeycloakAuthenticationError: {exc}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token expired or invalid", + ) from exc + except Exception as exc: + logger.error(f"Token refresh failed: {exc}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not refresh token", + ) from exc + class UserService: """Service for user-related operations.""" diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 06946e0..9a9dd44 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -4,10 +4,11 @@ import { useEffect, useCallback, useMemo, + useRef, type ReactNode, } from 'react' import type { User, AuthContextType } from '@/types' -import { authService } from '@/services' +import { authService, handleUnauthorized } from '@/services' export const AuthContext = createContext(undefined) @@ -15,9 +16,14 @@ interface AuthProviderProps { children: ReactNode } +// Refresh token 1 minute before it expires (Keycloak default is 5 min access token) +// We'll refresh every 4 minutes to be safe +const TOKEN_REFRESH_INTERVAL = 4 * 60 * 1000 + export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) + const refreshIntervalRef = useRef | null>(null) const refreshUser = useCallback(async () => { try { @@ -38,6 +44,11 @@ export function AuthProvider({ children }: AuthProviderProps) { const logout = useCallback(async () => { try { + // Clear the refresh interval + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + refreshIntervalRef.current = null + } await authService.logout() setUser(null) } catch (error) { @@ -52,6 +63,47 @@ export function AuthProvider({ children }: AuthProviderProps) { refreshUser() }, [refreshUser]) + // Set up silent token refresh when user is authenticated + useEffect(() => { + // Only set up interval if user is authenticated + if (!user) { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + refreshIntervalRef.current = null + } + return + } + + // Silently refresh token periodically + const silentRefresh = async () => { + console.log('Attempting silent token refresh...') + const success = await authService.refreshToken() + if (!success) { + console.log('Silent refresh failed, checking if session is still valid...') + // If refresh failed, check if we're still logged in + const isValid = await authService.checkSession() + if (!isValid) { + console.log('Session invalid, redirecting to login...') + handleUnauthorized() + } + } + } + + // Do an initial refresh after a short delay + const initialRefreshTimeout = setTimeout(silentRefresh, 10000) // 10 seconds after login + + // Then refresh periodically + refreshIntervalRef.current = setInterval(silentRefresh, TOKEN_REFRESH_INTERVAL) + + return () => { + clearTimeout(initialRefreshTimeout) + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + refreshIntervalRef.current = null + } + } + }, [user]) + // Determine if user is an auditor (role_id = 2) const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id]) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2349a4a..6555950 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,65 @@ import App from './App' import { AuthProvider, ProjectProvider } from '@/context' import './index.css' +// Global fetch interceptor to handle 401 Unauthorized responses +// This intercepts all fetch calls and attempts silent refresh before redirecting +const originalFetch = window.fetch +let isRefreshing = false +let refreshPromise: Promise | null = null + +window.fetch = async (...args) => { + const response = await originalFetch(...args) + + // Check if it's a 401 response from our API (not auth endpoints) + const url = typeof args[0] === 'string' ? args[0] : args[0] instanceof Request ? args[0].url : '' + const isApiCall = url.includes('/api/') && + !url.includes('/api/login') && + !url.includes('/api/callback') && + !url.includes('/api/auth/refresh') && + !url.includes('/api/auth/me') + + if (response.status === 401 && isApiCall) { + // Try to silently refresh the token + if (!isRefreshing) { + isRefreshing = true + refreshPromise = (async () => { + try { + const refreshResponse = await originalFetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }) + return refreshResponse.ok + } catch { + return false + } finally { + isRefreshing = false + refreshPromise = null + } + })() + } + + // Wait for the refresh attempt + const refreshSuccess = await refreshPromise + + if (refreshSuccess) { + // Retry the original request with fresh token + console.log('Token refreshed, retrying request...') + return originalFetch(...args) + } else { + // Refresh failed, redirect to login + console.log('Token refresh failed, redirecting to login...') + const currentPath = window.location.pathname + window.location.search + if (currentPath !== '/' && currentPath !== '/login') { + sessionStorage.setItem('redirectAfterLogin', currentPath) + } + window.location.href = '/api/login' + } + } + + return response +} + createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 5b9be35..62f7ac2 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -2,6 +2,48 @@ import type { User } from '@/types' const API_BASE_URL = '/api' +// Global flag to prevent multiple redirects +let isRedirecting = false + +/** + * Handle 401 Unauthorized responses by redirecting to login. + * This is called when the session cookie expires and refresh fails. + */ +export function handleUnauthorized(): void { + if (isRedirecting) return + isRedirecting = true + + // Store the current URL to redirect back after login + const currentPath = window.location.pathname + window.location.search + if (currentPath !== '/' && currentPath !== '/login') { + sessionStorage.setItem('redirectAfterLogin', currentPath) + } + + // Redirect to login + window.location.href = `${API_BASE_URL}/login` +} + +/** + * Wrapper around fetch that automatically handles 401 responses. + * Use this instead of fetch for authenticated API calls. + */ +export async function authenticatedFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const response = await fetch(input, { + ...init, + credentials: 'include', + }) + + if (response.status === 401) { + handleUnauthorized() + throw new Error('Session expired') + } + + return response +} + class AuthService { /** * Get the current authenticated user from the session cookie. @@ -32,6 +74,53 @@ class AuthService { } } + /** + * Check if the session is still valid. + * Returns true if authenticated, false otherwise. + */ + async checkSession(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/auth/me`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + return response.ok + } catch { + return false + } + } + + /** + * Silently refresh the access token using the refresh token. + * Returns true if successful, false otherwise. + */ + async refreshToken(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + console.log('Token refresh failed:', response.status) + return false + } + + const data = await response.json() + console.log('Token refreshed successfully, expires in:', data.expires_in, 'seconds') + return true + } catch (error) { + console.error('Failed to refresh token:', error) + return false + } + } + /** * Logout the current user by clearing the session cookie * and redirecting to Keycloak logout to end the SSO session. diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index b038237..05eb000 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,4 +1,4 @@ -export { authService } from './authService' +export { authService, handleUnauthorized, authenticatedFetch } from './authService' export { groupService } from './groupService' export type { Group } from './groupService' export { tagService } from './tagService'