Changed logic for auth token refresh
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user