"""Token issuance / rotation shared by the JSON API and the HTMX UI.""" import jwt from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import ( REFRESH_TOKEN_TYPE, create_access_token, create_refresh_token, decode_token, ) from app.crud import token as token_crud from app.models.user import User async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]: """Create an access + refresh token and persist the refresh token for revocation.""" access = create_access_token(user.id) refresh, payload = create_refresh_token(user.id) await token_crud.store( db, user_id=user.id, jti=payload["jti"], raw_token=refresh, expires_at=payload["exp"], # timezone-aware UTC datetime ) return access, refresh class RefreshError(Exception): pass async def rotate_refresh_token( db: AsyncSession, raw_refresh: str ) -> tuple[str, str, User]: """Validate a refresh token, revoke it, and issue a fresh pair. Raises ``RefreshError`` if the token is invalid, expired, or already revoked. """ try: payload = decode_token(raw_refresh) except jwt.PyJWTError as exc: raise RefreshError("Invalid refresh token") from exc if payload.get("type") != REFRESH_TOKEN_TYPE: raise RefreshError("Not a refresh token") jti = payload.get("jti") sub = payload.get("sub") if not jti or sub is None: raise RefreshError("Malformed refresh token") record = await token_crud.get_active(db, jti=jti, raw_token=raw_refresh) if record is None: raise RefreshError("Refresh token is revoked or expired") # Local import avoids a circular import at module load time. from app.crud import user as user_crud user = await user_crud.get(db, int(sub)) if user is None or not user.is_active: raise RefreshError("User is inactive or missing") # Rotate: revoke the presented token, then mint a new pair. await token_crud.revoke(db, record) access, refresh = await issue_token_pair(db, user) return access, refresh, user