diff --git a/backend/app/config.py b/backend/app/config.py index 1dbfd98..a5a270d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b349ebf..f4d1e90 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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.""" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 14816aa..536ef8a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { + 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); }, );