llm-ready-data / app /api /v1 /google_oauth.py
validops-east-1's picture
feat: friendly oauth scope map and sheets spreadsheet delete/list
a980424
Raw
History Blame Contribute Delete
8.38 kB
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)