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:
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
||||
# so tokens survive restarts.
|
||||
SECRET_KEY: str = ""
|
||||
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_URL: str = "redis://redis:6379/0"
|
||||
|
||||
@@ -155,6 +155,57 @@ def logout(
|
||||
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)
|
||||
def read_current_user(current_user: User = Depends(get_current_user)):
|
||||
"""Return the profile of the currently authenticated user."""
|
||||
|
||||
@@ -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