"""Author RAG — Auth + misc schema routes mounted at /api. Routes: POST /api/auth/register POST /api/auth/login POST /api/auth/refresh POST /api/auth/logout GET /api/auth/me """ import time from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.access.jwt_blacklist import blacklist_token, is_blacklisted from app.core.access.token_crypto import decode_jwt from app.dependencies import get_db, get_current_user, get_redis from pydantic import BaseModel, Field from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse, RefreshRequest from app.services.auth_service import AuthService, _create_token_pair router = APIRouter() class SsoExchangeRequest(BaseModel): """One-time code from SAML redirect.""" code: str = Field(..., min_length=20, max_length=200) def _cookie_secure() -> bool: return get_settings().APP_ENV == "production" def _set_refresh_cookie(response: Response, refresh_token: str) -> None: response.set_cookie( "refresh_token", refresh_token, httponly=True, secure=_cookie_secure(), samesite="lax", max_age=7 * 24 * 60 * 60, path="/", ) @router.post("/auth/sso/exchange", response_model=TokenResponse, tags=["Auth"]) async def sso_exchange( body: SsoExchangeRequest, response: Response, db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Exchange one-time SSO code (from SAML redirect) for JWT pair.""" from app.services.sso_exchange_service import consume_sso_code from app.models.user import User user_id = await consume_sso_code(redis, body.code) if not user_id: raise HTTPException(401, "Invalid or expired SSO login code. Please sign in again.") user = await db.get(User, user_id) if not user or not user.is_active: raise HTTPException(401, "Account not found or inactive.") tokens = _create_token_pair(user.id) _set_refresh_cookie(response, tokens["refresh_token"]) return { **tokens, "is_superadmin": user.role == "superadmin", "role": user.role, "author_slug": user.id, } @router.post("/auth/register", response_model=TokenResponse, status_code=201, tags=["Auth"]) async def register(payload: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)): """Register a new author account.""" try: result = await AuthService(db).register( email=payload.email, password=payload.password, full_name=payload.full_name, referral_code=payload.referral_code, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) _set_refresh_cookie(response, result["refresh_token"]) return result @router.post("/auth/login", response_model=TokenResponse, tags=["Auth"]) async def login(payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): """Authenticate author and return JWT token pair.""" result = await AuthService(db).login(email=payload.email, password=payload.password) _set_refresh_cookie(response, result["refresh_token"]) return result @router.post("/auth/refresh", response_model=TokenResponse, tags=["Auth"]) async def refresh( request: Request, response: Response, authorization: str = Header(default=""), db: AsyncSession = Depends(get_db), ): """Issue new JWT token pair and blacklist the old access token.""" from fastapi import HTTPException refresh_token = request.cookies.get("refresh_token") if not refresh_token: try: body = await request.json() refresh_token = body.get("refresh_token") except Exception: pass if not refresh_token: raise HTTPException(401, "Refresh token required") redis = await get_redis() # R-143: Reject blacklisted refresh tokens; blacklist submitted refresh jti try: refresh_payload = decode_jwt(refresh_token) if refresh_payload.get("type") != "refresh": raise HTTPException(401, "Not a refresh token") refresh_jti = refresh_payload.get("jti") if refresh_jti: try: if await is_blacklisted(redis, refresh_jti): raise HTTPException(401, "Refresh token has been revoked") except HTTPException: raise except Exception: pass # Redis down: fail-open (same as access-token blacklist check) except HTTPException: raise except Exception: raise HTTPException(401, "Invalid refresh token") refresh_ttl = max(int(refresh_payload.get("exp", 0) - time.time()), 1) # R-004: Blacklist old access token before issuing new pair if authorization and authorization.startswith("Bearer "): old_token = authorization.removeprefix("Bearer ").strip() try: old_payload = decode_jwt(old_token) old_jti = old_payload.get("jti") if old_jti: ttl = max(int(old_payload.get("exp", 0) - time.time()), 1) await blacklist_token(redis, old_jti, ttl) except Exception: pass # Best-effort blacklisting result = await AuthService(db).refresh_tokens(refresh_token) _set_refresh_cookie(response, result["refresh_token"]) # R-143: Blacklist the rotated refresh token after successful issue if refresh_jti: await blacklist_token(redis, refresh_jti, refresh_ttl) return result @router.post("/auth/logout", status_code=204, tags=["Auth"]) async def logout( request: Request, response: Response, authorization: str = Header(default=""), _=Depends(get_current_user), ): """Invalidate refresh token cookie and blacklist current JWT.""" redis = await get_redis() # R-144: Blacklist refresh token from cookie (read before clearing response cookie) refresh_token = request.cookies.get("refresh_token") # R-005: Blacklist current access token in Redis if authorization and authorization.startswith("Bearer "): token = authorization.removeprefix("Bearer ").strip() try: payload = decode_jwt(token) jti = payload.get("jti") if jti: ttl = max(int(payload.get("exp", 0) - time.time()), 1) await blacklist_token(redis, jti, ttl) except Exception: pass # Best-effort blacklisting if refresh_token: try: payload = decode_jwt(refresh_token) jti = payload.get("jti") if jti: ttl = max(int(payload.get("exp", 0) - time.time()), 1) await blacklist_token(redis, jti, ttl) except Exception: pass response.delete_cookie("refresh_token", path="/") @router.get("/auth/me", response_model=UserResponse, tags=["Auth"]) async def get_me(current_user=Depends(get_current_user)): """Return current author profile.""" return current_user