Spaces:
Running
Running
| 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) | |
| 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, | |
| ) | |
| 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, | |
| ) | |
| 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 "") | |
| ), | |
| ) | |
| 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, | |
| ) | |
| 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) | |