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

@@ -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<AuthContextType | undefined>(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<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const refreshIntervalRef = useRef<ReturnType<typeof setInterval> | 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])

View File

@@ -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<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(
<StrictMode>
<BrowserRouter>

View File

@@ -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<Response> {
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<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
* 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 type { Group } from './groupService'
export { tagService } from './tagService'