Changed logic for auth token refresh

This commit is contained in:
gulimabr
2025-12-02 15:37:37 -03:00
parent a65719b631
commit 5b7c499212
8 changed files with 351 additions and 4 deletions

View File

@@ -18,7 +18,7 @@ class Settings(BaseSettings):
cookie_secure: bool = Field(default=False, env="COOKIE_SECURE") cookie_secure: bool = Field(default=False, env="COOKIE_SECURE")
cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE") cookie_samesite: str = Field(default="lax", env="COOKIE_SAMESITE")
cookie_domain: str | None = Field(default=None, env="COOKIE_DOMAIN") 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") cookie_name: str = Field(default="access_token", env="COOKIE_NAME")
# Database settings # Database settings

View File

@@ -6,6 +6,9 @@ from src.models import TokenResponse, UserInfo
from src.service import AuthService, UserService from src.service import AuthService, UserService
from src.config import get_settings from src.config import get_settings
from src.database import get_db from src.database import get_db
import logging
logger = logging.getLogger(__name__)
# Initialize HTTPBearer security dependency # Initialize HTTPBearer security dependency
bearer_scheme = HTTPBearer() bearer_scheme = HTTPBearer()
@@ -13,6 +16,9 @@ bearer_scheme = HTTPBearer()
# Get settings # Get settings
settings = get_settings() settings = get_settings()
# Cookie name for refresh token
REFRESH_TOKEN_COOKIE = "refresh_token"
class AuthController: class AuthController:
""" """
@@ -85,6 +91,20 @@ class AuthController:
path="/", 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 return response
@staticmethod @staticmethod
@@ -148,15 +168,100 @@ class AuthController:
status_code=status.HTTP_200_OK status_code=status.HTTP_200_OK
) )
# Clear the authentication cookie # Clear the authentication cookies
response.delete_cookie( response.delete_cookie(
key=settings.cookie_name, key=settings.cookie_name,
path="/", path="/",
domain=settings.cookie_domain, domain=settings.cookie_domain,
) )
response.delete_cookie(
key=REFRESH_TOKEN_COOKIE,
path="/",
domain=settings.cookie_domain,
)
return response 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 @staticmethod
def protected_endpoint( def protected_endpoint(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),

View File

@@ -196,6 +196,19 @@ async def logout(request: Request):
return AuthController.logout() 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) # Define the protected endpoint (kept for API token-based access)
@app.get("/api/protected", response_model=UserInfo) @app.get("/api/protected", response_model=UserInfo)
async def protected_endpoint( async def protected_endpoint(

View File

@@ -115,6 +115,35 @@ class AuthService:
detail="Could not decode token", detail="Could not decode token",
) from exc ) 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: class UserService:
"""Service for user-related operations.""" """Service for user-related operations."""

View File

@@ -4,10 +4,11 @@ import {
useEffect, useEffect,
useCallback, useCallback,
useMemo, useMemo,
useRef,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import type { User, AuthContextType } from '@/types' import type { User, AuthContextType } from '@/types'
import { authService } from '@/services' import { authService, handleUnauthorized } from '@/services'
export const AuthContext = createContext<AuthContextType | undefined>(undefined) export const AuthContext = createContext<AuthContextType | undefined>(undefined)
@@ -15,9 +16,14 @@ interface AuthProviderProps {
children: ReactNode 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) { export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
try { try {
@@ -38,6 +44,11 @@ export function AuthProvider({ children }: AuthProviderProps) {
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
// Clear the refresh interval
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current)
refreshIntervalRef.current = null
}
await authService.logout() await authService.logout()
setUser(null) setUser(null)
} catch (error) { } catch (error) {
@@ -52,6 +63,47 @@ export function AuthProvider({ children }: AuthProviderProps) {
refreshUser() refreshUser()
}, [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) // Determine if user is an auditor (role_id = 2)
const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id]) const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id])

View File

@@ -5,6 +5,65 @@ import App from './App'
import { AuthProvider, ProjectProvider } from '@/context' import { AuthProvider, ProjectProvider } from '@/context'
import './index.css' 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<boolean> | 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>

View File

@@ -2,6 +2,48 @@ import type { User } from '@/types'
const API_BASE_URL = '/api' 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<Response> {
const response = await fetch(input, {
...init,
credentials: 'include',
})
if (response.status === 401) {
handleUnauthorized()
throw new Error('Session expired')
}
return response
}
class AuthService { class AuthService {
/** /**
* Get the current authenticated user from the session cookie. * 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<boolean> {
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<boolean> {
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 * Logout the current user by clearing the session cookie
* and redirecting to Keycloak logout to end the SSO session. * and redirecting to Keycloak logout to end the SSO session.

View File

@@ -1,4 +1,4 @@
export { authService } from './authService' export { authService, handleUnauthorized, authenticatedFetch } from './authService'
export { groupService } from './groupService' export { groupService } from './groupService'
export type { Group } from './groupService' export type { Group } from './groupService'
export { tagService } from './tagService' export { tagService } from './tagService'