Coverage for src/lexigram/auth/authn/google_oauth.py: 65%
137 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:58 +0800
1"""Google OAuth verification and claim normalization helpers."""
3from __future__ import annotations
5from datetime import UTC, datetime
6import inspect
7from typing import TYPE_CHECKING, Any, cast
9import jwt
11from lexigram.auth.exceptions import OAuth2Error
12from lexigram.contracts.auth import VerifiedIdentityClaims
13from lexigram.contracts.web import HTTPClientProtocol
14from lexigram.logging import get_logger
15from lexigram.serialization import dumps
17if TYPE_CHECKING:
18 from lexigram.contracts.web import HttpResponse
20logger = get_logger(__name__)
22GOOGLE_ISSUERS: tuple[str, str] = (
23 "https://accounts.google.com",
24 "accounts.google.com",
25)
26GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
27GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
28GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
31class GoogleOAuthService:
32 """Verify Google OAuth tokens and normalize the verified claims."""
34 def __init__(
35 self,
36 *,
37 client_id: str,
38 http_client: HTTPClientProtocol | None = None,
39 jwks_url: str = GOOGLE_JWKS_URL,
40 tokeninfo_url: str = GOOGLE_TOKENINFO_URL,
41 userinfo_url: str = GOOGLE_USERINFO_URL,
42 allowed_issuers: tuple[str, ...] = GOOGLE_ISSUERS,
43 jwks_cache_ttl_seconds: int = 300,
44 ) -> None:
45 if not client_id:
46 raise ValueError("Google OAuth client_id is required")
47 self.client_id = client_id
48 self.http_client = http_client
49 self.jwks_url = jwks_url
50 self.tokeninfo_url = tokeninfo_url
51 self.userinfo_url = userinfo_url
52 self.allowed_issuers = allowed_issuers
53 self.jwks_cache_ttl_seconds = max(0, jwks_cache_ttl_seconds)
54 self._jwks_cache: dict[str, Any] | None = None
55 self._jwks_cached_at: datetime | None = None
57 async def verify_token(self, token: str) -> VerifiedIdentityClaims:
58 """Verify a Google token, preferring ID-token JWKS validation.
60 Args:
61 token: Google-issued ID token or access token.
63 Returns:
64 Normalized, verified Google identity claims.
66 Raises:
67 OAuth2Error: If the token cannot be verified or normalized.
68 """
69 if self._looks_like_jwt(token):
70 try:
71 return await self.verify_id_token(token)
72 except OAuth2Error:
73 raise
74 except (jwt.PyJWTError, ValueError, KeyError) as exc:
75 logger.debug("google_id_token_verification_failed", error=str(exc))
76 raise OAuth2Error("Invalid Google ID token") from exc
78 return await self.verify_userinfo_token(token)
80 async def verify_id_token(self, token: str) -> VerifiedIdentityClaims:
81 """Verify a Google ID token against Google's JWKS."""
82 header = jwt.get_unverified_header(token)
83 kid = header.get("kid")
84 alg = header.get("alg") or "RS256"
86 jwks = await self._get_jwks()
87 key = self._select_jwk(jwks, kid)
88 public_key: Any = jwt.algorithms.RSAAlgorithm.from_jwk(dumps(key).decode())
90 try:
91 payload = jwt.decode(
92 token,
93 key=public_key,
94 algorithms=[cast("str", alg)],
95 audience=self.client_id,
96 options={
97 "require": ["exp", "iss", "sub", "aud"],
98 },
99 )
100 except jwt.PyJWTError as exc:
101 logger.warning(
102 "google_id_token_decode_failed",
103 error=str(exc),
104 kid=kid,
105 )
106 raise OAuth2Error("Invalid Google ID token") from exc
108 issuer = str(payload.get("iss") or "")
109 if issuer not in self.allowed_issuers:
110 raise OAuth2Error(f"Invalid Google token issuer: {issuer!r}")
112 email_verified = bool(payload.get("email_verified", False))
113 if not email_verified:
114 raise OAuth2Error("Google email is not verified")
116 return self._claims_from_payload(
117 payload,
118 issuer=issuer,
119 audience=str(payload.get("aud") or self.client_id),
120 )
122 async def verify_userinfo_token(self, token: str) -> VerifiedIdentityClaims:
123 """Verify a Google access token via the userinfo endpoint."""
124 payload = await self._request_json(
125 "GET",
126 self.userinfo_url,
127 headers={"Authorization": f"Bearer {token}"},
128 )
130 issuer = str(payload.get("iss") or "accounts.google.com")
131 email_verified = bool(payload.get("email_verified", False))
132 if not email_verified:
133 raise OAuth2Error("Google email is not verified")
135 return self._claims_from_payload(
136 payload,
137 issuer=issuer,
138 audience=self.client_id,
139 )
141 async def verify_tokeninfo(self, token: str) -> VerifiedIdentityClaims:
142 """Verify a Google token using the tokeninfo endpoint fallback."""
143 payload = await self._request_json(
144 "GET",
145 self.tokeninfo_url,
146 params={"id_token": token},
147 )
149 issuer = str(payload.get("iss") or "")
150 if issuer and issuer not in self.allowed_issuers:
151 raise OAuth2Error(f"Invalid Google token issuer: {issuer!r}")
153 audience = str(payload.get("aud") or self.client_id)
154 if audience != self.client_id:
155 raise OAuth2Error("Google token audience mismatch")
157 email_verified = payload.get("email_verified")
158 if email_verified is not None and not bool(email_verified):
159 raise OAuth2Error("Google email is not verified")
161 return self._claims_from_payload(
162 payload,
163 issuer=issuer or None,
164 audience=audience,
165 )
167 async def _get_jwks(self) -> dict[str, Any]:
168 """Fetch Google's JWKS, caching it for a short TTL."""
169 now = datetime.now(UTC)
170 if (
171 self._jwks_cache is not None
172 and self._jwks_cached_at is not None
173 and (now - self._jwks_cached_at).total_seconds()
174 < self.jwks_cache_ttl_seconds
175 ):
176 return self._jwks_cache
178 jwks = await self._request_json("GET", self.jwks_url)
179 self._jwks_cache = jwks
180 self._jwks_cached_at = now
181 return jwks
183 def _select_jwk(self, jwks: dict[str, Any], kid: str | None) -> dict[str, Any]:
184 """Select the matching JWK for a token header."""
185 keys = jwks.get("keys")
186 if not isinstance(keys, list) or not keys:
187 raise OAuth2Error("Google JWKS payload is empty")
189 if kid:
190 for key in keys:
191 if isinstance(key, dict) and key.get("kid") == kid:
192 return key
194 if len(keys) == 1 and isinstance(keys[0], dict):
195 return cast("dict[str, Any]", keys[0])
197 raise OAuth2Error("No matching Google signing key found")
199 async def _request_json(
200 self,
201 method: str,
202 url: str,
203 **kwargs: Any,
204 ) -> dict[str, Any]:
205 """Fetch JSON via the injected HTTP client or a temporary httpx client."""
206 if self.http_client is not None:
207 response = await self.http_client.request(method, url, **kwargs)
208 return await self._response_json(response)
210 import httpx
212 async with httpx.AsyncClient(timeout=10.0) as client:
213 httpx_response = await client.request(method, url, **kwargs)
214 httpx_response.raise_for_status()
215 return cast("dict[str, Any]", httpx_response.json())
217 async def _response_json(self, response: HttpResponse | Any) -> dict[str, Any]:
218 """Normalise framework HTTP responses and test doubles to JSON dicts."""
219 status = getattr(response, "status", None)
220 if status is None:
221 status = getattr(response, "status_code", None)
222 if isinstance(status, int) and status >= 400:
223 raise OAuth2Error(
224 f"Google request failed with HTTP {status}",
225 )
227 payload = getattr(response, "json", None)
228 if callable(payload):
229 payload = payload()
230 if inspect.isawaitable(payload):
231 payload = await payload
232 if not isinstance(payload, dict):
233 raise OAuth2Error("Google response did not contain JSON")
234 return cast("dict[str, Any]", payload)
236 def _claims_from_payload(
237 self,
238 payload: dict[str, Any],
239 *,
240 issuer: str | None,
241 audience: str | None,
242 ) -> VerifiedIdentityClaims:
243 """Convert Google payloads into normalized verified identity claims."""
244 expires_at = self._timestamp_to_datetime(payload.get("exp"))
245 issued_at = self._timestamp_to_datetime(payload.get("iat"))
246 provider_user_id_raw = (
247 payload.get("sub") or payload.get("id") or payload.get("provider_user_id")
248 )
249 if not provider_user_id_raw:
250 raise OAuth2Error("Google payload did not include a subject identifier")
251 provider_user_id = str(provider_user_id_raw)
253 return VerifiedIdentityClaims(
254 provider="google",
255 provider_user_id=provider_user_id,
256 email=payload.get("email"),
257 email_verified=bool(payload.get("email_verified", False)),
258 name=payload.get("name") or payload.get("given_name"),
259 picture=payload.get("picture"),
260 issuer=issuer,
261 audience=audience,
262 expires_at=expires_at,
263 issued_at=issued_at,
264 raw_data=dict(payload),
265 )
267 def _timestamp_to_datetime(self, value: Any) -> datetime | None:
268 """Convert a numeric UNIX timestamp to UTC datetime."""
269 if value is None:
270 return None
271 try:
272 return datetime.fromtimestamp(float(value), tz=UTC)
273 except (TypeError, ValueError, OSError):
274 return None
276 @staticmethod
277 def _looks_like_jwt(token: str) -> bool:
278 """Return True when the token resembles a JWT."""
279 return token.count(".") == 2
282__all__ = [
283 "GOOGLE_ISSUERS",
284 "GOOGLE_JWKS_URL",
285 "GOOGLE_TOKENINFO_URL",
286 "GOOGLE_USERINFO_URL",
287 "GoogleOAuthService",
288]