Seventh commit in the session-expiration-policy series. Wires the
backend taxonomy from commit 2 through to the frontend so users see
the right page (calm banner vs plain logout) when the refresh path
fails for different reasons.
- types/auth.ts: Token gains idle_expires_at + absolute_expires_at
(Optional ISO 8601 strings). The next commit adds the
useAuthSessionExpiry hook that reads these.
- api/auth.ts: OAuthCallbackResponse mirrors the same two fields.
- api/client.ts: refresh-failure handler now branches on the response
detail. session_expired_idle and session_expired_absolute both
redirect to /login?reason=session_expired (commit 8 adds the
banner that reads the query param); any other detail (most
commonly invalid_refresh_token) goes to plain /login. The bare
redirect is guarded against re-firing when the user is already on
/login. The refresh-success path now forwards the two new fields
into setTokens so the store stays current as the session ages.
- pages/OAuthCallbackPage.tsx: setTokens({...}) spreads
idle_expires_at + absolute_expires_at from the OAuth response.
No new tests — authStore.test still 2/2, tsc clean. The
useAuthSessionExpiry hook and the SessionExpiryToast that consume
the new fields land in commit 8 alongside the AccountSecurity page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: `${API_BASE_URL}/api/v1`,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
})
|
|
|
|
// Global error handler for shared cases only.
|
|
// By convention, 4xx errors are handled at the page/component level.
|
|
function handleGlobalError(error: AxiosError) {
|
|
// Network error (no response from server)
|
|
if (!error.response) {
|
|
if (error.code === 'ECONNABORTED') {
|
|
toast.error('Request timeout - please try again')
|
|
} else {
|
|
toast.error('Network error - please check your connection')
|
|
}
|
|
return
|
|
}
|
|
|
|
const status = error.response.status
|
|
const data = error.response.data as { detail?: string | unknown[] }
|
|
|
|
// Extract a displayable error message from the response.
|
|
// FastAPI returns `detail` as a string for most errors, but 422 validation
|
|
// errors return an array of objects — we must not pass those to toast.
|
|
const detail = typeof data?.detail === 'string' ? data.detail : undefined
|
|
|
|
// Don't show toast for 401 (handled by refresh interceptor)
|
|
if (status === 401) {
|
|
return
|
|
}
|
|
|
|
// Rate limit — always worth notifying
|
|
if (status === 429) {
|
|
toast.error(detail || 'Too many requests — please try again shortly')
|
|
return
|
|
}
|
|
|
|
// Client errors (4xx) remain page-owned to avoid duplicate/noisy toasts.
|
|
// Global handling only covers 401/429/5xx.
|
|
if (status >= 400 && status < 500) {
|
|
return
|
|
}
|
|
|
|
// Server errors (5xx) — show backend detail when available, else generic message
|
|
if (status >= 500) {
|
|
toast.error(detail || 'Server error - please try again later')
|
|
return
|
|
}
|
|
}
|
|
|
|
// Request interceptor - add auth token
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
const token = localStorage.getItem('access_token')
|
|
if (token && config.headers) {
|
|
config.headers.Authorization = `Bearer ${token}`
|
|
}
|
|
return config
|
|
},
|
|
(error) => Promise.reject(error)
|
|
)
|
|
|
|
// Refresh queue: when multiple requests hit 401 simultaneously,
|
|
// only the first triggers the actual refresh. Others wait for the result.
|
|
let isRefreshing = false
|
|
let refreshSubscribers: ((token: string) => void)[] = []
|
|
let refreshFailSubscribers: ((error: unknown) => void)[] = []
|
|
|
|
function onRefreshed(token: string) {
|
|
// Swap arrays before iterating — if a callback throws, the arrays
|
|
// are already cleared so the next refresh cycle starts clean.
|
|
const subscribers = refreshSubscribers
|
|
refreshSubscribers = []
|
|
refreshFailSubscribers = []
|
|
subscribers.forEach(cb => cb(token))
|
|
}
|
|
|
|
function onRefreshFailed(error: unknown) {
|
|
const failSubscribers = refreshFailSubscribers
|
|
refreshSubscribers = []
|
|
refreshFailSubscribers = []
|
|
failSubscribers.forEach(cb => cb(error))
|
|
}
|
|
|
|
// Response interceptor - handle token refresh
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
|
|
|
// Only handle 401s that haven't already been retried
|
|
if (error.response?.status !== 401 || originalRequest._retry) {
|
|
// Show global error toast for non-401 errors
|
|
// Pages can still catch errors explicitly for custom handling
|
|
handleGlobalError(error)
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
originalRequest._retry = true
|
|
|
|
const refreshToken = localStorage.getItem('refresh_token')
|
|
if (!refreshToken) {
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
// If a refresh is already in progress, queue this request
|
|
if (isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
refreshSubscribers.push((newToken: string) => {
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
|
}
|
|
resolve(apiClient(originalRequest))
|
|
})
|
|
refreshFailSubscribers.push((refreshError: unknown) => {
|
|
reject(refreshError)
|
|
})
|
|
})
|
|
}
|
|
|
|
// This is the first 401 — perform the refresh
|
|
isRefreshing = true
|
|
|
|
try {
|
|
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, null, {
|
|
headers: {
|
|
Authorization: `Bearer ${refreshToken}`,
|
|
},
|
|
})
|
|
|
|
const { access_token, refresh_token, idle_expires_at, absolute_expires_at } = response.data
|
|
localStorage.setItem('access_token', access_token)
|
|
localStorage.setItem('refresh_token', refresh_token)
|
|
|
|
// Sync Zustand auth store — include the new expiry fields so
|
|
// useAuthSessionExpiry stays accurate after each refresh.
|
|
useAuthStore.getState().setTokens({
|
|
access_token,
|
|
refresh_token,
|
|
token_type: 'bearer',
|
|
idle_expires_at,
|
|
absolute_expires_at,
|
|
})
|
|
|
|
isRefreshing = false
|
|
onRefreshed(access_token)
|
|
|
|
// Retry original request with new token
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
|
}
|
|
return apiClient(originalRequest)
|
|
} catch (refreshError) {
|
|
isRefreshing = false
|
|
onRefreshFailed(refreshError)
|
|
|
|
// Refresh failed — clear tokens and redirect to login. The redirect
|
|
// target depends on WHY the refresh failed (plan §4.10):
|
|
// - session_expired_idle / session_expired_absolute: the user hit a
|
|
// policy boundary. Show the calm "session ended for security"
|
|
// banner via ?reason=session_expired.
|
|
// - invalid_refresh_token (or anything else): plain logout, no
|
|
// banner — the user wasn't kicked by policy, the token just
|
|
// wasn't recognized.
|
|
const refreshAxiosErr = refreshError as AxiosError
|
|
const refreshDetail = (refreshAxiosErr.response?.data as { detail?: string })?.detail
|
|
const isPolicyExpiry =
|
|
refreshDetail === 'session_expired_idle' ||
|
|
refreshDetail === 'session_expired_absolute'
|
|
|
|
localStorage.removeItem('access_token')
|
|
localStorage.removeItem('refresh_token')
|
|
useAuthStore.getState().logout()
|
|
if (!window.location.pathname.startsWith('/login')) {
|
|
window.location.href = isPolicyExpiry
|
|
? '/login?reason=session_expired'
|
|
: '/login'
|
|
}
|
|
return Promise.reject(refreshError)
|
|
}
|
|
}
|
|
)
|
|
|
|
export default apiClient
|