import html import secrets from urllib.parse import urlencode import httpx from fastapi import HTTPException, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from pydantic import BaseModel from resonance_engine.config import settings STATE_COOKIE = "oauth_state" STATE_TTL_S = 600 REFRESH_COOKIE = "refresh_token" REFRESH_TTL_S = 30 * 86400 class TokenResponse(BaseModel): id_token: str | None = None access_token: str | None = None refresh_token: str | None = None expires_in: int = 3600 token_type: str | None = None scope: str | None = None def begin_login() -> RedirectResponse: state = secrets.token_urlsafe(32) params = { "response_type": "code", "client_id": settings.auth0_client_id, "redirect_uri": settings.auth0_callback_url, "scope": "openid profile email offline_access", "state": state, } if settings.auth0_audience: params["audience"] = settings.auth0_audience response = RedirectResponse( f"https://{settings.auth0_domain}/authorize?{urlencode(params)}" ) response.set_cookie( STATE_COOKIE, state, max_age=STATE_TTL_S, httponly=True, secure=settings.session_cookie_secure, samesite="lax", ) return response def oauth_error_page(error: str, description: str | None) -> HTMLResponse: """Render the OAuth error returned by Auth0 (escaped — reflected query params).""" return HTMLResponse( f"
{html.escape(error)}
" f"{html.escape(description or '')}
" f'', status_code=400, ) def complete_login(request: Request, code: str, state: str) -> RedirectResponse: expected = request.cookies.get(STATE_COOKIE) if not expected or not secrets.compare_digest(expected, state): raise HTTPException(status_code=400, detail="Invalid OAuth state") tokens = issue_token( { "grant_type": "authorization_code", "client_id": settings.auth0_client_id, "client_secret": settings.auth0_client_secret.get_secret_value(), "code": code, "redirect_uri": settings.auth0_callback_url, } ) response = RedirectResponse(settings.auth0_post_login_redirect) response.delete_cookie(STATE_COOKIE) store_tokens(response, tokens) return response def refresh_session(request: Request) -> Response: refresh_token = request.cookies.get(REFRESH_COOKIE) if not refresh_token: raise HTTPException(status_code=401, detail="No refresh token") tokens = issue_token( { "grant_type": "refresh_token", "client_id": settings.auth0_client_id, "client_secret": settings.auth0_client_secret.get_secret_value(), "refresh_token": refresh_token, } ) response = Response(status_code=204) store_tokens(response, tokens) return response def logout() -> RedirectResponse: params = urlencode( { "client_id": settings.auth0_client_id, "returnTo": settings.auth0_logout_redirect, } ) response = RedirectResponse(f"https://{settings.auth0_domain}/v2/logout?{params}") response.delete_cookie(settings.session_cookie_name) response.delete_cookie(REFRESH_COOKIE) return response def store_tokens(response: Response, tokens: TokenResponse) -> None: if not tokens.id_token: raise HTTPException(status_code=401, detail="No id_token in token response") response.set_cookie( settings.session_cookie_name, tokens.id_token, max_age=tokens.expires_in, httponly=True, secure=settings.session_cookie_secure, samesite="lax", ) if tokens.refresh_token: response.set_cookie( REFRESH_COOKIE, tokens.refresh_token, max_age=REFRESH_TTL_S, httponly=True, secure=settings.session_cookie_secure, samesite="lax", ) def issue_token(data: dict[str, str]) -> TokenResponse: with httpx.Client(timeout=10) as client: token_response = client.post( f"https://{settings.auth0_domain}/oauth/token", data=data ) if token_response.status_code != 200: raise HTTPException(status_code=401, detail="Token request failed") return TokenResponse.model_validate(token_response.json())