fix(auth): silent token refresh — active sessions no longer expire mid-use
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:
kitos
2026-06-02 15:54:15 +02:00
parent eee0560aeb
commit 46722aec19
3 changed files with 104 additions and 13 deletions

View File

@@ -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);
},
);