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.
|
# 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"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user