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