validops-east-1 commited on
Commit
2170658
·
1 Parent(s): b7dddbe

feat: add oauth api

Browse files
app/api/server.py CHANGED
@@ -105,6 +105,8 @@ async def lifespan(app: FastAPI):
105
  await close_redis(redis)
106
  await _vector_store_service.close_all()
107
  await pool_manager.close_all()
 
 
108
  from app.services.supabase import get_supabase_client
109
  client = get_supabase_client()
110
  if client:
 
105
  await close_redis(redis)
106
  await _vector_store_service.close_all()
107
  await pool_manager.close_all()
108
+ from app.api.v1.google_oauth import close_oauth_service
109
+ await close_oauth_service()
110
  from app.services.supabase import get_supabase_client
111
  client = get_supabase_client()
112
  if client:
app/api/v1/google_oauth.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+
8
+ from app.core.logger import get_logger
9
+ from app.models.schemas import (
10
+ GoogleOAuthAuthUrlRequest,
11
+ GoogleOAuthAuthUrlResponse,
12
+ GoogleOAuthCallbackRequest,
13
+ GoogleOAuthRefreshRequest,
14
+ GoogleOAuthTokenResponse,
15
+ GoogleOAuthVerifyRequest,
16
+ GoogleOAuthVerifyResponse,
17
+ )
18
+ from app.services.google_oauth_service import GoogleOAuthError, GoogleOAuthService
19
+ from app.services.jwt_service import JWTService
20
+ from app.config import get_settings
21
+
22
+ router = APIRouter(prefix="/google/oauth", tags=["Google OAuth"])
23
+ _logger = get_logger(__name__)
24
+ _settings = get_settings()
25
+ _jwt_service = JWTService()
26
+
27
+
28
+ _oauth_service = GoogleOAuthService()
29
+
30
+
31
+ def get_oauth_service() -> GoogleOAuthService:
32
+ return _oauth_service
33
+
34
+
35
+ async def close_oauth_service() -> None:
36
+ await _oauth_service.close()
37
+
38
+
39
+ def _generate_state() -> str:
40
+ result = _jwt_service.generate(
41
+ subject="google-oauth-state",
42
+ secret=_settings.jwt_secret_key,
43
+ expiry_minutes=_settings.google_oauth_state_ttl_minutes,
44
+ )
45
+ return result["token"]
46
+
47
+
48
+ def _verify_state(state: str) -> bool:
49
+ return _jwt_service.validate(
50
+ state, secret=_settings.jwt_secret_key
51
+ )["valid"]
52
+
53
+
54
+ def _http_error(exc: GoogleOAuthError) -> HTTPException:
55
+ return HTTPException(status_code=exc.status_code, detail=exc.message)
56
+
57
+
58
+ @router.post("/auth-url", response_model=GoogleOAuthAuthUrlResponse,
59
+ summary="Generate a Google OAuth authorization URL (Step 1)")
60
+ async def create_auth_url(
61
+ body: GoogleOAuthAuthUrlRequest,
62
+ service: GoogleOAuthService = Depends(get_oauth_service),
63
+ ):
64
+ start = time.perf_counter()
65
+ state = _generate_state()
66
+ auth_url = service.build_auth_url(
67
+ client_id=body.client_id,
68
+ redirect_uri=body.redirect_uri,
69
+ state=state,
70
+ scope=body.scope,
71
+ prompt=body.prompt,
72
+ access_type=body.access_type,
73
+ login_hint=body.login_hint,
74
+ include_granted_scopes=body.include_granted_scopes,
75
+ )
76
+ _logger.info(
77
+ "Generated Google OAuth auth URL for client '%s...' (%.2fms)",
78
+ body.client_id[:8],
79
+ (time.perf_counter() - start) * 1000,
80
+ )
81
+ return GoogleOAuthAuthUrlResponse(success=True, auth_url=auth_url, state=state)
82
+
83
+
84
+ @router.post("/callback", response_model=GoogleOAuthTokenResponse,
85
+ summary="Exchange the authorization code for tokens (Step 2)")
86
+ async def oauth_callback(
87
+ body: GoogleOAuthCallbackRequest,
88
+ service: GoogleOAuthService = Depends(get_oauth_service),
89
+ ):
90
+ start = time.perf_counter()
91
+
92
+ if not _verify_state(body.state):
93
+ raise HTTPException(
94
+ status_code=status.HTTP_403_FORBIDDEN,
95
+ detail="Invalid or expired state token. Possible CSRF attack.",
96
+ )
97
+
98
+ try:
99
+ tokens = await service.exchange_code(
100
+ client_id=body.client_id,
101
+ client_secret=body.client_secret,
102
+ code=body.code,
103
+ redirect_uri=body.redirect_uri,
104
+ )
105
+ except GoogleOAuthError as exc:
106
+ raise _http_error(exc) from exc
107
+
108
+ id_token_raw: Optional[str] = tokens.get("id_token")
109
+ user = None
110
+ if id_token_raw:
111
+ try:
112
+ user = await service.verify_id_token(id_token_raw, body.client_id)
113
+ except GoogleOAuthError as exc:
114
+ raise _http_error(exc) from exc
115
+
116
+ _logger.info(
117
+ "Google OAuth callback completed for client '%s...' (%.2fms)",
118
+ body.client_id[:8],
119
+ (time.perf_counter() - start) * 1000,
120
+ )
121
+ return GoogleOAuthTokenResponse(
122
+ success=True,
123
+ access_token=tokens["access_token"],
124
+ expires_in=int(tokens.get("expires_in", 0)),
125
+ refresh_token=tokens.get("refresh_token"),
126
+ id_token=id_token_raw,
127
+ token_type=tokens.get("token_type", "Bearer"),
128
+ scope=tokens.get("scope"),
129
+ user=user,
130
+ )
131
+
132
+
133
+ @router.post("/refresh", response_model=GoogleOAuthTokenResponse,
134
+ summary="Refresh an expired access token (Step 3)")
135
+ async def refresh_token(
136
+ body: GoogleOAuthRefreshRequest,
137
+ service: GoogleOAuthService = Depends(get_oauth_service),
138
+ ):
139
+ start = time.perf_counter()
140
+
141
+ try:
142
+ tokens = await service.refresh_access_token(
143
+ client_id=body.client_id,
144
+ client_secret=body.client_secret,
145
+ refresh_token=body.refresh_token,
146
+ )
147
+ except GoogleOAuthError as exc:
148
+ raise _http_error(exc) from exc
149
+
150
+ id_token_raw: Optional[str] = tokens.get("id_token")
151
+ user = None
152
+ if id_token_raw:
153
+ try:
154
+ user = await service.verify_id_token(id_token_raw, body.client_id)
155
+ except GoogleOAuthError as exc:
156
+ raise _http_error(exc) from exc
157
+
158
+ _logger.info(
159
+ "Google OAuth token refresh completed for client '%s...' (%.2fms)",
160
+ body.client_id[:8],
161
+ (time.perf_counter() - start) * 1000,
162
+ )
163
+ return GoogleOAuthTokenResponse(
164
+ success=True,
165
+ access_token=tokens["access_token"],
166
+ expires_in=int(tokens.get("expires_in", 0)),
167
+ refresh_token=body.refresh_token,
168
+ id_token=id_token_raw,
169
+ token_type=tokens.get("token_type", "Bearer"),
170
+ scope=tokens.get("scope"),
171
+ user=user,
172
+ )
173
+
174
+
175
+ @router.post("/verify", response_model=GoogleOAuthVerifyResponse,
176
+ summary="Verify a Google ID token and return the user profile")
177
+ async def verify_id_token(
178
+ body: GoogleOAuthVerifyRequest,
179
+ service: GoogleOAuthService = Depends(get_oauth_service),
180
+ ):
181
+ start = time.perf_counter()
182
+
183
+ try:
184
+ user = await service.verify_id_token(body.id_token, body.client_id)
185
+ except GoogleOAuthError as exc:
186
+ raise _http_error(exc) from exc
187
+
188
+ _logger.info(
189
+ "Google OAuth ID token verified for client '%s...' (%.2fms)",
190
+ body.client_id[:8],
191
+ (time.perf_counter() - start) * 1000,
192
+ )
193
+ return GoogleOAuthVerifyResponse(success=True, valid=True, user=user)
app/api/v1/router.py CHANGED
@@ -12,6 +12,7 @@ from app.api.v1 import (
12
  database,
13
  embeddings,
14
  google_maps,
 
15
  json_extract,
16
  keys_extract,
17
  qr_decoder,
@@ -53,6 +54,7 @@ api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
53
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
54
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
55
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
 
56
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
57
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
58
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
 
12
  database,
13
  embeddings,
14
  google_maps,
15
+ google_oauth,
16
  json_extract,
17
  keys_extract,
18
  qr_decoder,
 
54
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
55
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
56
  api_v1_router.include_router(google_maps.router, tags=["Google Maps"])
57
+ api_v1_router.include_router(google_oauth.router, tags=["Google OAuth"])
58
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
59
  api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
60
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
app/config.py CHANGED
@@ -102,6 +102,17 @@ class Settings(BaseSettings):
102
  google_maps_timeout: int = 15
103
  google_maps_max_retries: int = 2
104
 
 
 
 
 
 
 
 
 
 
 
 
105
  # Scheduler settings
106
  max_http_timeout: float = 300.0
107
  default_scheduler_timezone: str = "UTC"
 
102
  google_maps_timeout: int = 15
103
  google_maps_max_retries: int = 2
104
 
105
+ # Google OAuth 2.0 (OpenID Connect) Sign-In settings
106
+ google_oauth_auth_url: str = "https://accounts.google.com/o/oauth2/v2/auth"
107
+ google_oauth_token_url: str = "https://oauth2.googleapis.com/token"
108
+ google_oauth_userinfo_url: str = "https://openidconnect.googleapis.com/v1/userinfo"
109
+ google_oauth_jwks_url: str = "https://www.googleapis.com/oauth2/v3/certs"
110
+ google_oauth_default_scope: str = "openid email profile"
111
+ google_oauth_timeout: float = 15.0
112
+ google_oauth_max_retries: int = 2
113
+ google_oauth_state_ttl_minutes: int = 10
114
+ google_oauth_jwks_ttl_seconds: int = 3600
115
+
116
  # Scheduler settings
117
  max_http_timeout: float = 300.0
118
  default_scheduler_timezone: str = "UTC"
app/models/schemas.py CHANGED
@@ -883,3 +883,73 @@ class GoogleQueryAutocompleteResponse(BaseModel):
883
  predictions: List[GoogleAutocompletePrediction] = []
884
  count: int = 0
885
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
883
  predictions: List[GoogleAutocompletePrediction] = []
884
  count: int = 0
885
  error: Optional[str] = None
886
+
887
+
888
+ # ---------------------------------------------------------------------------
889
+ # Google OAuth 2.0 / OpenID Connect Sign-In
890
+ # ---------------------------------------------------------------------------
891
+
892
+ class GoogleOAuthUserInfo(BaseModel):
893
+ sub: str = Field(..., description="Google user ID (subject)")
894
+ email: str = Field(default="", description="User email address")
895
+ email_verified: bool = Field(default=False, description="Whether the email has been verified by Google")
896
+ name: Optional[str] = Field(None, description="Full display name")
897
+ given_name: Optional[str] = Field(None, description="Given name")
898
+ family_name: Optional[str] = Field(None, description="Family name")
899
+ picture: Optional[str] = Field(None, description="Profile picture URL")
900
+ locale: Optional[str] = Field(None, description="Locale (e.g. 'en', 'en-US')")
901
+
902
+
903
+ class GoogleOAuthAuthUrlRequest(BaseModel):
904
+ client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID")
905
+ redirect_uri: str = Field(..., min_length=1, max_length=1000, description="Registered callback URL")
906
+ scope: str = Field(default="openid email profile", min_length=1, max_length=2000, description="Space-separated OAuth scopes")
907
+ prompt: Optional[str] = Field(None, pattern="^(none|consent|select_account)$", description="Google prompt parameter")
908
+ access_type: Optional[str] = Field(None, pattern="^(online|offline)$", description="Whether to return a refresh token (offline)")
909
+ login_hint: Optional[str] = Field(None, max_length=500, description="Prefilled user email to sign in as")
910
+ include_granted_scopes: bool = Field(default=False, description="Append previously granted scopes")
911
+
912
+
913
+ class GoogleOAuthAuthUrlResponse(BaseModel):
914
+ success: bool
915
+ auth_url: str
916
+ state: str
917
+ error: Optional[str] = None
918
+
919
+
920
+ class GoogleOAuthCallbackRequest(BaseModel):
921
+ client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID")
922
+ client_secret: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client Secret")
923
+ code: str = Field(..., min_length=1, description="The authorization code returned by Google")
924
+ redirect_uri: str = Field(..., min_length=1, max_length=1000, description="Must match the URI used to build the auth URL")
925
+ state: str = Field(..., min_length=1, description="The CSRF state token returned from the auth URL step")
926
+
927
+
928
+ class GoogleOAuthRefreshRequest(BaseModel):
929
+ client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID")
930
+ client_secret: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client Secret")
931
+ refresh_token: str = Field(..., min_length=1, description="The refresh token obtained during the callback step")
932
+
933
+
934
+ class GoogleOAuthVerifyRequest(BaseModel):
935
+ client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID (the audience of the ID token)")
936
+ id_token: str = Field(..., min_length=1, description="The Google ID token to verify")
937
+
938
+
939
+ class GoogleOAuthTokenResponse(BaseModel):
940
+ success: bool
941
+ access_token: str
942
+ expires_in: int
943
+ refresh_token: Optional[str] = None
944
+ id_token: Optional[str] = None
945
+ token_type: str = "Bearer"
946
+ scope: Optional[str] = None
947
+ user: Optional[GoogleOAuthUserInfo] = None
948
+ error: Optional[str] = None
949
+
950
+
951
+ class GoogleOAuthVerifyResponse(BaseModel):
952
+ success: bool
953
+ valid: bool
954
+ user: Optional[GoogleOAuthUserInfo] = None
955
+ error: Optional[str] = None
app/services/google_oauth_service.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Dict, Optional
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+ import jwt
10
+
11
+ from app.config import get_settings
12
+ from app.core.logger import get_logger
13
+ from app.models.schemas import GoogleOAuthUserInfo
14
+
15
+ _logger = get_logger(__name__)
16
+ _settings = get_settings()
17
+
18
+ # Retryable transient HTTP status codes when talking to Google.
19
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
20
+
21
+
22
+ class GoogleOAuthError(Exception):
23
+ """Raised for upstream Google OAuth failures that map to a client-facing error."""
24
+
25
+ def __init__(self, message: str, status_code: int = 400) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.status_code = status_code
29
+
30
+
31
+ class GoogleJWKSCache:
32
+ """Asynchronously fetches and caches Google's public JWKS keys.
33
+
34
+ Keys are cached for a short TTL to avoid hammering Google's cert endpoint,
35
+ and re-fetched on demand if the requested ``kid`` is missing (key rotation).
36
+ """
37
+
38
+ def __init__(self) -> None:
39
+ self._keys: Dict[str, Dict[str, Any]] = {}
40
+ self._last_fetched: float = 0.0
41
+ self._lock = asyncio.Lock()
42
+
43
+ def _is_expired(self) -> bool:
44
+ return (
45
+ not self._keys
46
+ or time.time() - self._last_fetched >= _settings.google_oauth_jwks_ttl_seconds
47
+ )
48
+
49
+ async def get_public_key(self, client: httpx.AsyncClient, kid: str) -> Any:
50
+ """Return the RSA public key for the given key id (``kid``)."""
51
+ if self._is_expired():
52
+ await self._refresh(client)
53
+
54
+ jwk = self._keys.get(kid)
55
+ if jwk is None:
56
+ # Key may have rotated; force a refresh before giving up.
57
+ await self._refresh(client)
58
+ jwk = self._keys.get(kid)
59
+
60
+ if jwk is None:
61
+ raise GoogleOAuthError(
62
+ f"Google signing key not found for kid '{kid}'", status_code=401
63
+ )
64
+
65
+ return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
66
+
67
+ async def _refresh(self, client: httpx.AsyncClient) -> None:
68
+ async with self._lock:
69
+ if not self._is_expired():
70
+ return
71
+ try:
72
+ response = await client.get(
73
+ _settings.google_oauth_jwks_url, timeout=10.0
74
+ )
75
+ response.raise_for_status()
76
+ data = response.json()
77
+ self._keys = {
78
+ key["kid"]: key
79
+ for key in data.get("keys", [])
80
+ if isinstance(key, dict) and "kid" in key
81
+ }
82
+ self._last_fetched = time.time()
83
+ _logger.info("Google JWKS refreshed (%d keys)", len(self._keys))
84
+ except httpx.HTTPError as exc:
85
+ _logger.error("Failed to refresh Google JWKS: %s", exc)
86
+ raise GoogleOAuthError(
87
+ "Unable to fetch Google signing keys", status_code=502
88
+ ) from exc
89
+
90
+
91
+ class GoogleOAuthService:
92
+ """Abstraction over the Google OAuth 2.0 / OpenID Connect flow."""
93
+
94
+ def __init__(self) -> None:
95
+ self._client: Optional[httpx.AsyncClient] = None
96
+ self._client_lock = asyncio.Lock()
97
+ self._jwks = GoogleJWKSCache()
98
+
99
+ # ------------------------------------------------------------------
100
+ # HTTP client management
101
+ # ------------------------------------------------------------------
102
+
103
+ async def _get_client(self) -> httpx.AsyncClient:
104
+ if self._client is None or self._client.is_closed:
105
+ async with self._client_lock:
106
+ if self._client is None or self._client.is_closed:
107
+ self._client = httpx.AsyncClient(
108
+ timeout=httpx.Timeout(_settings.google_oauth_timeout),
109
+ limits=httpx.Limits(
110
+ max_connections=100,
111
+ max_keepalive_connections=20,
112
+ keepalive_expiry=30,
113
+ ),
114
+ )
115
+ return self._client
116
+
117
+ async def close(self) -> None:
118
+ async with self._client_lock:
119
+ if self._client is not None and not self._client.is_closed:
120
+ await self._client.aclose()
121
+ self._client = None
122
+
123
+ # ------------------------------------------------------------------
124
+ # Authorization URL (Step 1)
125
+ # ------------------------------------------------------------------
126
+
127
+ def build_auth_url(
128
+ self,
129
+ *,
130
+ client_id: str,
131
+ redirect_uri: str,
132
+ state: str,
133
+ scope: str,
134
+ prompt: Optional[str] = None,
135
+ access_type: Optional[str] = None,
136
+ login_hint: Optional[str] = None,
137
+ include_granted_scopes: bool = False,
138
+ ) -> str:
139
+ params: Dict[str, Any] = {
140
+ "client_id": client_id,
141
+ "redirect_uri": redirect_uri,
142
+ "response_type": "code",
143
+ "scope": scope,
144
+ "state": state,
145
+ }
146
+ if prompt:
147
+ params["prompt"] = prompt
148
+ if access_type:
149
+ params["access_type"] = access_type
150
+ if login_hint:
151
+ params["login_hint"] = login_hint
152
+ if include_granted_scopes:
153
+ params["include_granted_scopes"] = "true"
154
+
155
+ return f"{_settings.google_oauth_auth_url}?{urlencode(params)}"
156
+
157
+ # ------------------------------------------------------------------
158
+ # Token endpoint helpers
159
+ # ------------------------------------------------------------------
160
+
161
+ async def _post_token(
162
+ self, data: Dict[str, str], *, context: str
163
+ ) -> Dict[str, Any]:
164
+ client = await self._get_client()
165
+ last_error: Optional[str] = None
166
+
167
+ for attempt in range(1 + _settings.google_oauth_max_retries):
168
+ try:
169
+ response = await client.post(_settings.google_oauth_token_url, data=data)
170
+ if response.status_code == 200:
171
+ return response.json()
172
+
173
+ if response.status_code in _RETRYABLE_STATUS:
174
+ last_error = (
175
+ f"Google OAuth upstream error HTTP {response.status_code}"
176
+ )
177
+ _logger.warning(
178
+ "%s: transient HTTP %s (attempt %d/%d)",
179
+ context,
180
+ response.status_code,
181
+ attempt + 1,
182
+ 1 + _settings.google_oauth_max_retries,
183
+ )
184
+ else:
185
+ return self._parse_token_error(response, context=context)
186
+
187
+ except httpx.TimeoutException:
188
+ last_error = "Google OAuth request timed out"
189
+ _logger.warning(
190
+ "%s: timeout (attempt %d/%d)",
191
+ context,
192
+ attempt + 1,
193
+ 1 + _settings.google_oauth_max_retries,
194
+ )
195
+ except httpx.RequestError as exc:
196
+ last_error = f"Google OAuth request failed: {exc}"
197
+ _logger.warning(
198
+ "%s: %s (attempt %d/%d)",
199
+ context,
200
+ last_error,
201
+ attempt + 1,
202
+ 1 + _settings.google_oauth_max_retries,
203
+ )
204
+
205
+ if attempt < _settings.google_oauth_max_retries:
206
+ await asyncio.sleep(2 ** attempt)
207
+
208
+ raise GoogleOAuthError(last_error or "Unknown Google OAuth error", status_code=502)
209
+
210
+ @staticmethod
211
+ def _parse_token_error(
212
+ response: httpx.Response, *, context: str
213
+ ) -> Dict[str, Any]:
214
+ try:
215
+ body = response.json()
216
+ error: str = body.get("error", "") or ""
217
+ error_description: str = body.get("error_description", "") or ""
218
+ except Exception:
219
+ error = ""
220
+ error_description = ""
221
+ body = {}
222
+
223
+ _logger.warning("%s: Google token error '%s': %s", context, error, error_description)
224
+
225
+ if error == "invalid_grant":
226
+ message = error_description or (
227
+ "The provided code or refresh token is invalid, expired, or has been revoked."
228
+ )
229
+ raise GoogleOAuthError(message, status_code=401)
230
+ if error == "invalid_client":
231
+ raise GoogleOAuthError(
232
+ error_description or "Invalid client_id or client_secret.", status_code=401
233
+ )
234
+ if error == "invalid_request":
235
+ raise GoogleOAuthError(
236
+ error_description or "Malformed OAuth request.", status_code=400
237
+ )
238
+ if error == "access_denied":
239
+ raise GoogleOAuthError(
240
+ error_description or "Access denied by the user.", status_code=403
241
+ )
242
+
243
+ # Google does not return a structured error body for some failures.
244
+ if response.status_code >= 500:
245
+ raise GoogleOAuthError(
246
+ "Google OAuth service is temporarily unavailable.", status_code=502
247
+ )
248
+ if response.status_code == 429:
249
+ raise GoogleOAuthError(
250
+ "Google OAuth rate limit exceeded. Please retry later.", status_code=429
251
+ )
252
+
253
+ message = error_description or f"Google OAuth error (HTTP {response.status_code})."
254
+ return {"error": True, "message": message, "raw": body}
255
+
256
+ # ------------------------------------------------------------------
257
+ # Code exchange (Step 2)
258
+ # ------------------------------------------------------------------
259
+
260
+ async def exchange_code(
261
+ self,
262
+ *,
263
+ client_id: str,
264
+ client_secret: str,
265
+ code: str,
266
+ redirect_uri: str,
267
+ ) -> Dict[str, Any]:
268
+ data = {
269
+ "client_id": client_id,
270
+ "client_secret": client_secret,
271
+ "code": code,
272
+ "grant_type": "authorization_code",
273
+ "redirect_uri": redirect_uri,
274
+ }
275
+ tokens = await self._post_token(data, context="authorization code exchange")
276
+ self._raise_for_token_error(tokens)
277
+ return tokens
278
+
279
+ async def refresh_access_token(
280
+ self,
281
+ *,
282
+ client_id: str,
283
+ client_secret: str,
284
+ refresh_token: str,
285
+ ) -> Dict[str, Any]:
286
+ data = {
287
+ "client_id": client_id,
288
+ "client_secret": client_secret,
289
+ "refresh_token": refresh_token,
290
+ "grant_type": "refresh_token",
291
+ }
292
+ tokens = await self._post_token(data, context="refresh token exchange")
293
+ self._raise_for_token_error(tokens)
294
+ return tokens
295
+
296
+ @staticmethod
297
+ def _raise_for_token_error(tokens: Dict[str, Any]) -> None:
298
+ if tokens.get("error"):
299
+ raise GoogleOAuthError(tokens.get("message", "Unknown OAuth error"), status_code=400)
300
+ if not tokens.get("access_token"):
301
+ raise GoogleOAuthError(
302
+ "Google did not return an access token.", status_code=502
303
+ )
304
+
305
+ # ------------------------------------------------------------------
306
+ # ID token verification
307
+ # ------------------------------------------------------------------
308
+
309
+ async def verify_id_token(
310
+ self, id_token: str, client_id: str
311
+ ) -> GoogleOAuthUserInfo:
312
+ client = await self._get_client()
313
+
314
+ try:
315
+ unverified = jwt.get_unverified_header(id_token)
316
+ kid = unverified.get("kid")
317
+ alg = unverified.get("alg")
318
+ except jwt.DecodeError as exc:
319
+ raise GoogleOAuthError("Malformed ID token.", status_code=400) from exc
320
+
321
+ if not kid or alg != "RS256":
322
+ raise GoogleOAuthError(
323
+ "ID token does not use an RS256 signature.", status_code=401
324
+ )
325
+
326
+ try:
327
+ public_key = await self._jwks.get_public_key(client, kid)
328
+ except GoogleOAuthError:
329
+ raise
330
+
331
+ try:
332
+ payload = jwt.decode(
333
+ id_token,
334
+ key=public_key,
335
+ algorithms=["RS256"],
336
+ audience=client_id,
337
+ issuer=["accounts.google.com", "https://accounts.google.com"],
338
+ options={
339
+ "verify_exp": True,
340
+ "verify_iat": True,
341
+ "verify_aud": True,
342
+ "verify_iss": True,
343
+ "require": ["exp", "iat", "sub", "email"],
344
+ },
345
+ )
346
+ except jwt.ExpiredSignatureError as exc:
347
+ raise GoogleOAuthError("ID token has expired.", status_code=401) from exc
348
+ except jwt.InvalidAudienceError as exc:
349
+ raise GoogleOAuthError(
350
+ "ID token audience does not match the provided client_id.", status_code=401
351
+ ) from exc
352
+ except jwt.InvalidIssuerError as exc:
353
+ raise GoogleOAuthError(
354
+ "ID token issuer is not Google.", status_code=401
355
+ ) from exc
356
+ except jwt.PyJWTError as exc:
357
+ _logger.warning("ID token verification failed: %s", exc)
358
+ raise GoogleOAuthError("Invalid ID token.", status_code=401) from exc
359
+
360
+ return self._user_info_from_claims(payload)
361
+
362
+ # ------------------------------------------------------------------
363
+ # Userinfo (fallback profile source)
364
+ # ------------------------------------------------------------------
365
+
366
+ async def fetch_userinfo(self, access_token: str) -> GoogleOAuthUserInfo:
367
+ client = await self._get_client()
368
+ try:
369
+ response = await client.get(
370
+ _settings.google_oauth_userinfo_url,
371
+ headers={"Authorization": f"Bearer {access_token}"},
372
+ )
373
+ response.raise_for_status()
374
+ except httpx.HTTPStatusError as exc:
375
+ if exc.response.status_code in (401, 403):
376
+ raise GoogleOAuthError(
377
+ "Access token is invalid or has expired.", status_code=401
378
+ ) from exc
379
+ raise GoogleOAuthError(
380
+ "Failed to fetch Google user profile.", status_code=502
381
+ ) from exc
382
+ except httpx.RequestError as exc:
383
+ raise GoogleOAuthError(
384
+ "Failed to reach Google userinfo endpoint.", status_code=502
385
+ ) from exc
386
+
387
+ return self._user_info_from_claims(response.json())
388
+
389
+ @staticmethod
390
+ def _user_info_from_claims(payload: Dict[str, Any]) -> GoogleOAuthUserInfo:
391
+ return GoogleOAuthUserInfo(
392
+ sub=payload.get("sub", ""),
393
+ email=payload.get("email", ""),
394
+ email_verified=bool(payload.get("email_verified", False)),
395
+ name=payload.get("name"),
396
+ given_name=payload.get("given_name"),
397
+ family_name=payload.get("family_name"),
398
+ picture=payload.get("picture"),
399
+ locale=payload.get("locale"),
400
+ )
requirements.txt CHANGED
@@ -33,6 +33,7 @@ phonenumbers>=8.13.0
33
  sqlglot>=20.0.0
34
  tiktoken>=0.9.0
35
  PyJWT>=2.9.0
 
36
 
37
  # Authentication
38
  argon2-cffi>=23.1.0
 
33
  sqlglot>=20.0.0
34
  tiktoken>=0.9.0
35
  PyJWT>=2.9.0
36
+ cryptography>=42.0.0
37
 
38
  # Authentication
39
  argon2-cffi>=23.1.0