from __future__ import annotations import time from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from app.core.logger import get_logger from app.models.schemas import ( GoogleOAuthAuthUrlRequest, GoogleOAuthAuthUrlResponse, GoogleOAuthCallbackRequest, GoogleOAuthCallbackResponse, GoogleOAuthRefreshRequest, GoogleOAuthTokenResponse, GoogleOAuthVerifyRequest, GoogleOAuthVerifyResponse, ) from app.services.google_oauth_service import GoogleOAuthError, GoogleOAuthService from app.services.google_scope_map import resolve_scope_list from app.services.jwt_service import JWTService from app.config import get_settings router = APIRouter(prefix="/google/oauth", tags=["Google OAuth"]) _logger = get_logger(__name__) _settings = get_settings() _jwt_service = JWTService() _oauth_service = GoogleOAuthService() def get_oauth_service() -> GoogleOAuthService: return _oauth_service async def close_oauth_service() -> None: await _oauth_service.close() def _generate_state() -> str: result = _jwt_service.generate( subject="google-oauth-state", secret=_settings.jwt_secret_key, expiry_minutes=_settings.google_oauth_state_ttl_minutes, ) return result["token"] def _verify_state(state: str) -> bool: return _jwt_service.validate( state, secret=_settings.jwt_secret_key )["valid"] def _http_error(exc: GoogleOAuthError) -> HTTPException: return HTTPException(status_code=exc.status_code, detail=exc.message) @router.post("/auth-url", response_model=GoogleOAuthAuthUrlResponse, summary="Generate a Google OAuth authorization URL (Step 1)") async def create_auth_url( body: GoogleOAuthAuthUrlRequest, service: GoogleOAuthService = Depends(get_oauth_service), ): start = time.perf_counter() resolved_scopes, scope_errors, dropped_scopes = resolve_scope_list(body.scopes) if scope_errors: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="; ".join(scope_errors), ) state = _generate_state() auth_url = service.build_auth_url( client_id=body.client_id, redirect_uri=body.redirect_uri, state=state, scope=" ".join(resolved_scopes), prompt=body.prompt, access_type=body.access_type, login_hint=body.login_hint, include_granted_scopes=body.include_granted_scopes, ) _logger.info( "Generated Google OAuth auth URL for client '%s...' (%.2fms)", body.client_id[:8], (time.perf_counter() - start) * 1000, ) return GoogleOAuthAuthUrlResponse( success=True, auth_url=auth_url, state=state, resolved_scopes=resolved_scopes, dropped_scopes=dropped_scopes, ) @router.post("/callback", response_model=GoogleOAuthTokenResponse, summary="Exchange the authorization code for tokens (Step 2)") async def oauth_callback( body: GoogleOAuthCallbackRequest, service: GoogleOAuthService = Depends(get_oauth_service), ): start = time.perf_counter() if not _verify_state(body.state): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired state token. Possible CSRF attack.", ) try: tokens = await service.exchange_code( client_id=body.client_id, client_secret=body.client_secret, code=body.code, redirect_uri=body.redirect_uri, ) except GoogleOAuthError as exc: raise _http_error(exc) from exc id_token_raw: Optional[str] = tokens.get("id_token") user = None if id_token_raw: try: user = await service.verify_id_token(id_token_raw, body.client_id) except GoogleOAuthError as exc: raise _http_error(exc) from exc _logger.info( "Google OAuth callback completed for client '%s...' (%.2fms)", body.client_id[:8], (time.perf_counter() - start) * 1000, ) return GoogleOAuthTokenResponse( success=True, access_token=tokens["access_token"], expires_in=int(tokens.get("expires_in", 0)), refresh_token=tokens.get("refresh_token"), id_token=id_token_raw, token_type=tokens.get("token_type", "Bearer"), scope=tokens.get("scope"), user=user, ) @router.get("/callback", response_model=GoogleOAuthCallbackResponse, summary="Capture the data Google returns after the user authorizes (browser redirect)") async def oauth_callback_redirect( state: Optional[str] = None, code: Optional[str] = None, error: Optional[str] = None, error_description: Optional[str] = None, scope: Optional[str] = None, authuser: Optional[str] = None, prompt: Optional[str] = None, ): """Handle the browser redirect Google sends to ``redirect_uri``. Captures the data Google returns after authentication — ``code`` on success, or ``error``/``error_description`` when access is denied — and returns it so the client can exchange the code via ``POST /callback``. """ start = time.perf_counter() if not state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing 'state' parameter in the OAuth callback.", ) if not _verify_state(state): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired state token. Possible CSRF attack.", ) _logger.info( "Google OAuth callback captured data for state '%s...' (%.2fms)", state[:12], (time.perf_counter() - start) * 1000, ) return GoogleOAuthCallbackResponse( success=error is None, state=state, code=code, error=error, error_description=error_description, scope=scope, authuser=authuser, prompt=prompt, message=( "Authorization successful. Exchange the code via POST /google/oauth/callback." if error is None else f"Authorization failed: {error}." + (f" {error_description}" if error_description else "") ), ) @router.post("/refresh", response_model=GoogleOAuthTokenResponse, summary="Refresh an expired access token (Step 3)") async def refresh_token( body: GoogleOAuthRefreshRequest, service: GoogleOAuthService = Depends(get_oauth_service), ): start = time.perf_counter() try: tokens = await service.refresh_access_token( client_id=body.client_id, client_secret=body.client_secret, refresh_token=body.refresh_token, ) except GoogleOAuthError as exc: raise _http_error(exc) from exc id_token_raw: Optional[str] = tokens.get("id_token") user = None if id_token_raw: try: user = await service.verify_id_token(id_token_raw, body.client_id) except GoogleOAuthError as exc: raise _http_error(exc) from exc _logger.info( "Google OAuth token refresh completed for client '%s...' (%.2fms)", body.client_id[:8], (time.perf_counter() - start) * 1000, ) return GoogleOAuthTokenResponse( success=True, access_token=tokens["access_token"], expires_in=int(tokens.get("expires_in", 0)), refresh_token=body.refresh_token, id_token=id_token_raw, token_type=tokens.get("token_type", "Bearer"), scope=tokens.get("scope"), user=user, ) @router.post("/verify", response_model=GoogleOAuthVerifyResponse, summary="Verify a Google ID token and return the user profile") async def verify_id_token( body: GoogleOAuthVerifyRequest, service: GoogleOAuthService = Depends(get_oauth_service), ): start = time.perf_counter() try: user = await service.verify_id_token(body.id_token, body.client_id) except GoogleOAuthError as exc: raise _http_error(exc) from exc _logger.info( "Google OAuth ID token verified for client '%s...' (%.2fms)", body.client_id[:8], (time.perf_counter() - start) * 1000, ) return GoogleOAuthVerifyResponse(success=True, valid=True, user=user)