""" OAuth state anti-CSRF para el flujo de login con ORCID. El parámetro `state` se genera en `/auth/orcid/authorize`, se guarda en una cookie HttpOnly + SameSite=Lax con TTL corto, y se valida en el callback. Si el `state` falta, no coincide o ha expirado, el login se rechaza. """ from __future__ import annotations import hmac import secrets from datetime import datetime, timezone from urllib.parse import urlparse from fastapi import HTTPException, status from starlette.requests import Request from starlette.responses import Response from app.core.config import settings _STATE_BYTES = 32 def generate_state() -> str: return secrets.token_urlsafe(_STATE_BYTES) def attach_state_cookie(response: Response, state: str) -> None: """ Persiste el `state` en una cookie segura y devuelve el valor crudo. `Secure` debe ser True en cualquier flujo HTTPS (p. ej. ngrok en local); no basta con `ENVIRONMENT=production`, o el navegador puede descartar la cookie y el callback fallará con «OAuth state missing». """ redirect_https = urlparse(settings.ORCID_REDIRECT_URI).scheme == "https" use_secure = settings.is_production or redirect_https response.set_cookie( key=settings.ORCID_OAUTH_STATE_COOKIE, value=state, max_age=settings.ORCID_OAUTH_STATE_TTL_SECONDS, secure=use_secure, httponly=True, samesite="lax", path="/", ) def clear_state_cookie(response: Response) -> None: response.delete_cookie( key=settings.ORCID_OAUTH_STATE_COOKIE, path="/", ) def validate_state(request: Request, received_state: str | None) -> None: """ Compara el state recibido en el callback con el almacenado en cookie. Lanza 400 si no coincide o falta. Comparación en tiempo constante. """ if not settings.ORCID_OAUTH_STATE_ENABLED: return cookie_value = request.cookies.get(settings.ORCID_OAUTH_STATE_COOKIE) if not cookie_value or not received_state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state missing", ) if not hmac.compare_digest(cookie_value.encode("utf-8"), received_state.encode("utf-8")): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch", ) def now_ts() -> int: return int(datetime.now(timezone.utc).timestamp())