fix(auth): silent token refresh — active sessions no longer expire mid-use
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Problem: 15-minute tokens with no refresh mechanism kicked users to login even when actively using the app. Fixes: 1. config.py: raise ACCESS_TOKEN_EXPIRE_MINUTES from 15 → 480 (8h). Reasonable for an enterprise internal tool; still configurable via env. 2. POST /auth/refresh: new endpoint that reads the current aegis_token cookie and issues a fresh token if the session is still valid. Returns the new token in the cookie + body (same shape as /auth/login). 3. frontend/api/client.ts: response interceptor now attempts a silent refresh on 401 before redirecting to login: - Calls POST /auth/refresh once per failed request - If refresh succeeds: retries the original request transparently - If refresh fails: redirects to /login as before - Deduplicates concurrent refresh attempts (refresh once, resolve all) - Never attempts refresh on /auth/refresh or /auth/login themselves Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import axios, { type AxiosError } from "axios";
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
|
||||
@@ -9,31 +9,71 @@ const client = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Response interceptor for error handling
|
||||
// ── Silent token refresh ────────────────────────────────────────────
|
||||
// Prevent multiple concurrent refresh attempts
|
||||
let _refreshing = false;
|
||||
let _refreshWaiters: Array<(ok: boolean) => void> = [];
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
if (_refreshing) {
|
||||
// Another request is already refreshing — wait for it
|
||||
return new Promise((resolve) => _refreshWaiters.push(resolve));
|
||||
}
|
||||
|
||||
_refreshing = true;
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
_refreshWaiters.forEach((cb) => cb(true));
|
||||
return true;
|
||||
} catch {
|
||||
_refreshWaiters.forEach((cb) => cb(false));
|
||||
return false;
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
_refreshWaiters = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response interceptor ────────────────────────────────────────────
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<{ detail?: string; code?: string }>) => {
|
||||
async (error: AxiosError<{ detail?: string; code?: string }>) => {
|
||||
const status = error.response?.status;
|
||||
|
||||
// On 401, redirect to login (cookie is managed by the browser)
|
||||
if (status === 401) {
|
||||
// Only redirect if not already on login page
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
|
||||
|
||||
// On 401, attempt a silent token refresh once before redirecting
|
||||
if (status === 401 && !originalRequest?._retried) {
|
||||
// Don't attempt refresh on the refresh or login endpoints themselves
|
||||
const url = originalRequest?.url ?? "";
|
||||
if (!url.includes("/auth/refresh") && !url.includes("/auth/login")) {
|
||||
originalRequest._retried = true;
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
// Retry the original request with the new cookie
|
||||
return client(originalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed or not applicable — redirect to login
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Extract error message from response
|
||||
const message =
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
"An unexpected error occurred";
|
||||
|
||||
// Create a more descriptive error
|
||||
|
||||
const enhancedError = new Error(message);
|
||||
(enhancedError as Error & { status?: number; code?: string }).status = status;
|
||||
(enhancedError as Error & { status?: number }).status = status;
|
||||
(enhancedError as Error & { code?: string }).code = error.response?.data?.code;
|
||||
|
||||
|
||||
return Promise.reject(enhancedError);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user