| """Google OAuth2 (authorization-code flow) — no third-party dep. |
| |
| Flow: |
| 1. GET /auth/google -> 302 redirect to Google's consent page |
| 2. Google redirects back to /auth/google/callback?code=... |
| 3. We exchange `code` for tokens (POST to oauth2.googleapis.com/token) |
| 4. We fetch the user (openid userinfo endpoint) |
| 5. On success: set session cookie, redirect to dashboard.html |
| |
| Docs: https://developers.google.com/identity/openid-connect |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import urllib.parse |
|
|
| import httpx |
|
|
| GOOGLE_API = "https://oauth2.googleapis.com" |
| AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" |
| TOKEN_URL = GOOGLE_API + "/token" |
| USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo" |
|
|
| |
| CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "") |
| CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "") |
| REDIRECT_URI = os.environ.get("GOOGLE_REDIRECT_URI", "") |
| HOSTED_DOMAIN = os.environ.get("GOOGLE_HOSTED_DOMAIN", "").strip() |
|
|
| |
| SCOPES = "openid email profile" |
|
|
|
|
| def is_configured() -> bool: |
| """True only if all required env vars are set (used to show/hide the button).""" |
| return bool(CLIENT_ID and CLIENT_SECRET and REDIRECT_URI and os.environ.get("SESSION_SECRET")) |
|
|
|
|
| def authorize_url(state: str) -> str: |
| """Build the full authorize URL with query params for the OAuth redirect.""" |
| params = { |
| "client_id": CLIENT_ID, |
| "redirect_uri": REDIRECT_URI, |
| "response_type": "code", |
| "scope": SCOPES, |
| "state": state, |
| "prompt": "consent", |
| "access_type": "online", |
| } |
| if HOSTED_DOMAIN: |
| params["hd"] = HOSTED_DOMAIN |
| return AUTHORIZE_URL + "?" + urllib.parse.urlencode(params) |
|
|
|
|
| async def exchange_code(code: str, client: httpx.AsyncClient) -> dict | None: |
| """Exchange an authorization code for tokens. Returns None on failure.""" |
| data = { |
| "client_id": CLIENT_ID, |
| "client_secret": CLIENT_SECRET, |
| "grant_type": "authorization_code", |
| "code": code, |
| "redirect_uri": REDIRECT_URI, |
| } |
| headers = {"Content-Type": "application/x-www-form-urlencoded"} |
| try: |
| r = await client.post(TOKEN_URL, data=data, headers=headers, timeout=15.0) |
| except Exception: |
| return None |
| if r.status_code != 200: |
| return None |
| return r.json() |
|
|
|
|
| async def fetch_user(access_token: str, client: httpx.AsyncClient) -> dict | None: |
| """Fetch the OpenID userinfo with the access token. Returns None on failure.""" |
| headers = {"Authorization": f"Bearer {access_token}"} |
| try: |
| r = await client.get(USERINFO_URL, headers=headers, timeout=15.0) |
| except Exception: |
| return None |
| if r.status_code != 200: |
| return None |
| return r.json() |
|
|