fix(backend): resolve refresh-token expiry deadlock, missing Jira on normal campaign approval, discarded threat-actor campaign start_date

- /auth/refresh now allows a short grace window past expiry and checks
  the blacklist, so an active session's silent refresh no longer fails
  the instant its own token expires
- normal manager /approve flow now creates Jira tickets for the campaign
  and its already-linked tests, matching the admin-only /activate path
- GenerateFromActorPayload now accepts start_date and threads it through
  to the new campaign instead of silently discarding it
This commit is contained in:
kitos
2026-07-07 13:42:26 +02:00
parent fefe70baed
commit bd26b09827
6 changed files with 236 additions and 31 deletions
+28 -6
View File
@@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm
# Import datetime, timezone from datetime
from datetime import datetime, timezone
# Import jwt (PyJWT)
import jwt
from jwt.exceptions import PyJWTError as JWTError
@@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError
from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth
from app.auth import blacklist_token, create_access_token, verify_password
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
# Import settings from app.config
from app.config import settings
@@ -272,19 +275,28 @@ def logout(
return {"detail": "Logged out"}
# A token that has *just* expired can still be refreshed within this window.
# Without this grace period, `/auth/refresh` decodes the same token with the
# same strict expiry check as every other endpoint — so by the time a 401
# triggers a refresh attempt, the refresh call itself has also expired and
# the session is unrecoverable. The grace window only widens the refresh
# check; a brand-new token is always issued with the full expiry.
_REFRESH_GRACE_SECONDS = 15 * 60
@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.
"""Issue a new access token if the current one is valid or recently expired.
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.
session while the user is actively using the app. Expiry is checked
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
PyJWT's built-in check, so a token that expired moments ago can still
be refreshed. Revoked (blacklisted) tokens are never refreshable.
"""
if not aegis_token:
raise PermissionViolation("No active session")
@@ -294,10 +306,20 @@ def refresh_token(
aegis_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_exp": False},
)
except JWTError:
raise PermissionViolation("Session expired — please log in again")
jti: str | None = payload.get("jti")
if jti and is_token_blacklisted(jti):
raise PermissionViolation("Session expired — please log in again")
exp = payload.get("exp")
now = datetime.now(timezone.utc).timestamp()
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
raise PermissionViolation("Session expired — please log in again")
username: str | None = payload.get("sub")
if not username:
raise PermissionViolation("Invalid session")