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

@@ -20,7 +20,7 @@ class Settings(BaseSettings):
# so tokens survive restarts. # so tokens survive restarts.
SECRET_KEY: str = "" SECRET_KEY: str = ""
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # short-lived for security; configurable via env ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — /auth/refresh extends active sessions
# ── Redis ───────────────────────────────────────────────────────── # ── Redis ─────────────────────────────────────────────────────────
REDIS_URL: str = "redis://redis:6379/0" REDIS_URL: str = "redis://redis:6379/0"

View File

@@ -155,6 +155,57 @@ def logout(
return {"detail": "Logged out"} return {"detail": "Logged out"}
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
response: Response,
aegis_token: str | None = Cookie(None),
db: Session = Depends(get_db),
):
"""Issue a new access token if the current one is valid.
Called automatically by the frontend when it detects an expired
session while the user is actively using the app. If the current
cookie token is still valid (not blacklisted, not expired), a fresh
token is issued and the cookie is renewed — keeping the session alive
without requiring re-authentication.
"""
if not aegis_token:
raise PermissionViolation("No active session")
try:
payload = jwt.decode(
aegis_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
except JWTError:
raise PermissionViolation("Session expired — please log in again")
username: str | None = payload.get("sub")
if not username:
raise PermissionViolation("Invalid session")
user = db.query(User).filter(User.username == username).first()
if user is None or not user.is_active:
raise PermissionViolation("Account not found or disabled")
if getattr(user, "must_change_password", False):
raise PermissionViolation("Password change required before refreshing session")
# Issue a fresh token with a new expiry
new_token = create_access_token(data={"sub": user.username})
response.set_cookie(
key=_COOKIE_NAME,
value=new_token,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
path="/",
)
return TokenResponse(access_token=new_token)
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)): def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the profile of the currently authenticated user.""" """Return the profile of the currently authenticated user."""

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"; const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
@@ -9,15 +9,56 @@ const client = axios.create({
withCredentials: true, 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( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail?: string; code?: string }>) => { async (error: AxiosError<{ detail?: string; code?: string }>) => {
const status = error.response?.status; const status = error.response?.status;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean };
// On 401, redirect to login (cookie is managed by the browser) // On 401, attempt a silent token refresh once before redirecting
if (status === 401) { if (status === 401 && !originalRequest?._retried) {
// Only redirect if not already on login page // 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") { if (window.location.pathname !== "/login") {
window.location.href = "/login"; window.location.href = "/login";
} }
@@ -29,9 +70,8 @@ client.interceptors.response.use(
error.message || error.message ||
"An unexpected error occurred"; "An unexpected error occurred";
// Create a more descriptive error
const enhancedError = new Error(message); 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; (enhancedError as Error & { code?: string }).code = error.response?.data?.code;
return Promise.reject(enhancedError); return Promise.reject(enhancedError);