"""Google Sign-In (OAuth 2.0 / OpenID Connect) — stdlib only. Authorization-code flow with PKCE. The ID token is received directly from Google's token endpoint over TLS (server-to-server), so per Google's own guidance we validate its claims (iss/aud/exp/email_verified) without re-verifying the RS256 signature — no JWT/JWKS dependency required. """ from __future__ import annotations import base64 import hashlib import json import os import secrets import urllib.parse import urllib.request from datetime import datetime, timezone AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" TOKEN_URL = "https://oauth2.googleapis.com/token" USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" _VALID_ISS = {"accounts.google.com", "https://accounts.google.com"} def configured() -> bool: return bool( os.environ.get("GOODNEWS_GOOGLE_CLIENT_ID") and os.environ.get("GOODNEWS_GOOGLE_CLIENT_SECRET") ) def _b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def new_pkce() -> tuple[str, str]: """Return (code_verifier, code_challenge) for S256 PKCE.""" verifier = _b64url(secrets.token_bytes(48)) challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) return verifier, challenge def auth_url(redirect_uri: str, state: str, code_challenge: str) -> str: params = { "client_id": os.environ["GOODNEWS_GOOGLE_CLIENT_ID"], "redirect_uri": redirect_uri, "response_type": "code", "scope": "openid email profile", "state": state, "code_challenge": code_challenge, "code_challenge_method": "S256", "access_type": "online", "prompt": "select_account", } return AUTH_URL + "?" + urllib.parse.urlencode(params) def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict: data = urllib.parse.urlencode( { "code": code, "client_id": os.environ["GOODNEWS_GOOGLE_CLIENT_ID"], "client_secret": os.environ["GOODNEWS_GOOGLE_CLIENT_SECRET"], "redirect_uri": redirect_uri, "grant_type": "authorization_code", "code_verifier": code_verifier, } ).encode("ascii") request = urllib.request.Request( TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, ) with urllib.request.urlopen(request, timeout=15) as response: return json.loads(response.read()) def fetch_userinfo(access_token: str) -> dict: """Call the OIDC userinfo endpoint — a reliable source for the profile picture if the ID token happens to omit it. Best-effort; returns {} on any error.""" request = urllib.request.Request( USERINFO_URL, headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"} ) try: with urllib.request.urlopen(request, timeout=10) as response: return json.loads(response.read()) except Exception: return {} def _decode_jwt_payload(token: str) -> dict: parts = token.split(".") if len(parts) != 3: raise ValueError("malformed id_token") payload = parts[1] payload += "=" * (-len(payload) % 4) return json.loads(base64.urlsafe_b64decode(payload)) def verify_id_token(id_token: str) -> dict: """Validate an ID token's claims (not its signature — see module docstring). Returns {"sub", "email", "name"} or raises ValueError. """ claims = _decode_jwt_payload(id_token) if claims.get("iss") not in _VALID_ISS: raise ValueError("unexpected issuer") if claims.get("aud") != os.environ.get("GOODNEWS_GOOGLE_CLIENT_ID"): raise ValueError("audience mismatch") if datetime.now(timezone.utc).timestamp() > float(claims.get("exp", 0)): raise ValueError("id_token expired") if not claims.get("email") or claims.get("email_verified") not in (True, "true"): raise ValueError("email not verified") return { "sub": str(claims["sub"]), "email": claims["email"], "name": claims.get("name"), "picture": claims.get("picture"), }