"""Sign in with an external provider, and connect one to an existing account. One engine, two providers (Discord, Google). Adding a third means adding a ``Provider`` entry and nothing else — the flow, the signed state, the account resolution and the session issuance are all provider-agnostic on purpose. That is not tidiness for its own sake: account resolution is the security-critical part, and a second hand-rolled copy is where a subtle divergence becomes a takeover. Two flows over one authorization-code grant, told apart by the signed `state`: link — /oauth/{provider}/link/start a signed-in user attaches an identity login — /oauth/{provider}/login/start an anonymous visitor authenticates `state` is signed with the app's auth secret (stdlib hmac, no new dependency) and carries the provider, the purpose, and — for a link — the Thermograph user id. All three are inside the HMAC, so a state cannot be replayed, bound to a different account, replayed as the other flow, or replayed against a different provider. How a login resolves to an account, in order: 1. An ``oauth_account`` row for (provider, subject) — the durable key, no email needed and immune to the user changing their address at the provider. 2. Otherwise the provider's email, but **only if it reports it verified**. That flag is the sole evidence the person owns the address; matching an existing Thermograph account on an unverified one would hand that account to whoever typed the address into Discord or Google. Unverified is refused outright. 3. No account for that email: create one, flagged ``oauth_only`` because it has no password its owner has ever seen. **Legacy routes.** Discord shipped first, under /discord/*, and its callback URL is registered in Discord's developer portal — so /discord/link/callback keeps working forever, and the /discord/* aliases stay because the frontend deploys independently of this service and an older one must keep working against a newer backend. """ from __future__ import annotations import base64 import hashlib import hmac import json import os import time import urllib.parse from dataclasses import dataclass, field from typing import Callable import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from accounts.db import get_async_session from accounts.models import OAuthAccount, User from accounts.users import ( SECRET, attach_session, current_active_user, current_user_optional, get_access_token_db, get_user_manager, ) from core import audit router = APIRouter(tags=["oauth"]) BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/") # How long a start->callback round trip may take before the signed state expires. _STATE_TTL = 600 _TIMEOUT = httpx.Timeout(10.0) # --- providers ---------------------------------------------------------------- def _parse_discord(p: dict) -> tuple[str, str, bool]: return (str(p.get("id") or ""), str(p.get("email") or "").strip().lower(), p.get("verified") is True) def _parse_google(p: dict) -> tuple[str, str, bool]: # OpenID Connect userinfo. `email_verified` arrives as a real bool from the # JSON endpoint, but Google has historically also serialised it as the string # "true" in some responses, so accept both rather than silently reading a # verified address as unverified and refusing every Google login. verified = p.get("email_verified") return (str(p.get("sub") or ""), str(p.get("email") or "").strip().lower(), verified is True or str(verified).lower() == "true") @dataclass(frozen=True) class Provider: name: str label: str authorize_url: str token_url: str userinfo_url: str # Linking needs only an identity; signing in also needs the address to resolve # or create an account by, which is a separate scope the user is prompted for. scope_link: str scope_login: str # Where this provider's callback lives. Discord's is grandfathered to the path # already registered in its portal; anything new gets the canonical one. callback_path: str parse: Callable[[dict], tuple[str, str, bool]] # Extra authorize-URL params. Google needs prompt=consent to re-issue consent, # and Discord takes the same key, so this stays per-provider rather than shared. extra_authorize: dict = field(default_factory=dict) PROVIDERS: dict[str, Provider] = { "discord": Provider( name="discord", label="Discord", authorize_url="https://discord.com/api/oauth2/authorize", token_url="https://discord.com/api/oauth2/token", userinfo_url="https://discord.com/api/users/@me", scope_link="identify", scope_login="identify email", callback_path="/api/v2/discord/link/callback", parse=_parse_discord, extra_authorize={"prompt": "consent"}, ), "google": Provider( name="google", label="Google", authorize_url="https://accounts.google.com/o/oauth2/v2/auth", token_url="https://oauth2.googleapis.com/token", userinfo_url="https://openidconnect.googleapis.com/v1/userinfo", # Google has no identity-only scope worth the name: openid alone yields a # subject but no address, and we want the address recorded on the link too. scope_link="openid email", scope_login="openid email", callback_path="/api/v2/oauth/google/callback", parse=_parse_google, # access_type=online: there is no offline work to do on the user's behalf, # so asking for a refresh token would be collecting a credential we would # never use. include_granted_scopes keeps incremental consent sane. extra_authorize={"prompt": "consent", "access_type": "online", "include_granted_scopes": "true"}, ), } # Credentials, read once at import. A dict rather than per-provider constants so a # test can enable or disable one provider without touching the others. CREDENTIALS: dict[str, tuple[str, str]] = { "discord": (os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip(), os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()), "google": (os.environ.get("THERMOGRAPH_GOOGLE_CLIENT_ID", "").strip(), os.environ.get("THERMOGRAPH_GOOGLE_CLIENT_SECRET", "").strip()), } def enabled(provider: str) -> bool: """Whether this provider is configured. An unconfigured provider surfaces no UI and every one of its routes bounces, so a half-set environment is inert rather than broken.""" cid, secret = CREDENTIALS.get(provider, ("", "")) return bool(cid and secret) def _provider_or_404(name: str) -> Provider: p = PROVIDERS.get(name) if p is None: raise HTTPException(status_code=404, detail="Unknown provider.") return p # --- signed state ------------------------------------------------------------ def _b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _unb64(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _sign_state(uid: str | None, purpose: str, provider: str) -> str: payload = _b64(json.dumps( {"u": uid or "", "t": int(time.time()), "p": purpose, "pr": provider} ).encode()) mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32] return f"{payload}.{mac}" def _verify_state(state: str, purpose: str, provider: str) -> str | None: """The Thermograph user id this state was minted for, or None if it's missing, tampered, expired, or was minted for a different purpose or provider. A login state has no user id yet, so it verifies as ``""`` — falsy, but distinct from the None that means reject. Callers must test against None, not truthiness, or a valid login state reads as a failure. """ try: payload, mac = state.split(".", 1) except (ValueError, AttributeError): return None expect = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32] if not hmac.compare_digest(mac, expect): return None try: data = json.loads(_unb64(payload)) except (ValueError, json.JSONDecodeError): return None if time.time() - float(data.get("t", 0)) > _STATE_TTL: return None if data.get("p") != purpose: return None # States minted before the provider field existed are Discord's; they age out # in _STATE_TTL, so this fallback only spans a deploy. if data.get("pr", "discord") != provider: return None return data.get("u") or "" # --- redirects --------------------------------------------------------------- def _redirect_uri(request: Request, provider: Provider) -> str: """The callback URL, matched to how the app is actually reached (scheme/host + base path). Must equal the redirect registered in the provider's console.""" proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("host") or request.url.netloc return f"{proto}://{host}{BASE}{provider.callback_path}" def _authorize_redirect(request: Request, provider: Provider, scope: str, state: str) -> RedirectResponse: cid, _ = CREDENTIALS[provider.name] params = { "client_id": cid, "redirect_uri": _redirect_uri(request, provider), "response_type": "code", "scope": scope, "state": state, **provider.extra_authorize, } return RedirectResponse( url=f"{provider.authorize_url}?{urllib.parse.urlencode(params)}", status_code=303) def _account_redirect(status: str, provider: str) -> RedirectResponse: """Back to the alerts page, which surfaces the outcome as a toast; 303 so the browser issues a GET after the callback. Emits `discord=` as well for Discord, because the frontend deploys independently of this service and the one in production reads that parameter. Drop it once no deployed frontend predates `oauth=`.""" params = {"oauth": status, "provider": provider} if provider == "discord": params["discord"] = status return RedirectResponse(url=f"{BASE}/alerts?{urllib.parse.urlencode(params)}", status_code=303) # --- provider API ------------------------------------------------------------- async def _fetch_profile(request: Request, provider: Provider, code: str) -> dict | None: """Trade the authorization code for the provider's user object, or None if any step fails. Callers treat None as a graceful error, never a crash.""" cid, secret = CREDENTIALS[provider.name] try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: tok = await client.post(provider.token_url, data={ "client_id": cid, "client_secret": secret, "grant_type": "authorization_code", "code": code, "redirect_uri": _redirect_uri(request, provider), }, headers={"Content-Type": "application/x-www-form-urlencoded"}) if tok.status_code >= 300: return None access = tok.json().get("access_token") if not access: return None me = await client.get(provider.userinfo_url, headers={"Authorization": f"Bearer {access}"}) if me.status_code >= 300: return None profile = me.json() except Exception: # noqa: BLE001 - any network/parse failure is a graceful error return None return profile if isinstance(profile, dict) else None # --- persistence -------------------------------------------------------------- async def _account_for(session: AsyncSession, provider: str, subject: str) -> OAuthAccount | None: return (await session.execute( select(OAuthAccount).where(OAuthAccount.provider == provider, OAuthAccount.subject == subject) )).scalar_one_or_none() async def _link_row(session: AsyncSession, user_id, provider: str, subject: str, email: str | None) -> None: """Bind (provider, subject) to a user, plus the Discord-only side effect. discord_id is a *delivery* address, not an identity — notify.py DMs it — so the Discord link has to write it as well as the oauth_account row. Turning DMs on here mirrors the original behaviour: linking is an active opt-in, and the user can mute it (POST /discord/dm) while staying linked. """ session.add(OAuthAccount(user_id=user_id, provider=provider, subject=subject, email=email or None)) if provider == "discord": await session.execute( update(User).where(User.id == user_id) .values(discord_id=subject, discord_dm=True)) await session.commit() async def _create_user(user_manager, email: str) -> User: """Create an account for an identity that matched none of ours. Deliberately goes around ``UserManager.create()``: there is no password to validate, and its ``on_after_register`` mails a confirmation link for an address the provider has already verified. The generated password exists only because ``hashed_password`` is NOT NULL — nobody ever sees it, which is what ``oauth_only`` records. """ password = user_manager.password_helper.generate() return await user_manager.user_db.create({ "email": email, "hashed_password": user_manager.password_helper.hash(password), "is_active": True, "is_superuser": False, "is_verified": True, "oauth_only": True, }) # --- routes ------------------------------------------------------------------- @router.get("/oauth/config") async def oauth_config(): """Which providers this server can actually complete a flow with. The sign-in modal and account menu render from this, so an unconfigured provider shows no dead-end UI and configuring one later makes it appear on its own.""" return {"providers": [ {"name": p.name, "label": p.label, "enabled": enabled(p.name)} for p in PROVIDERS.values() ]} @router.get("/oauth/{provider}/link/start") async def link_start(provider: str, request: Request, user=Depends(current_active_user)): p = _provider_or_404(provider) if not enabled(provider): return _account_redirect("unavailable", provider) return _authorize_redirect(request, p, p.scope_link, _sign_state(str(user.id), "link", provider)) @router.get("/oauth/{provider}/login/start") async def login_start(provider: str, request: Request, user=Depends(current_user_optional)): """Sign in with a provider. Unauthenticated by design — this is how you get a session, not something you do with one.""" p = _provider_or_404(provider) if not enabled(provider): return _account_redirect("unavailable", provider) # Someone already signed in who lands here wants to connect, not to be switched # into whichever account the external identity resolves to. if user is not None: return _authorize_redirect(request, p, p.scope_link, _sign_state(str(user.id), "link", provider)) return _authorize_redirect(request, p, p.scope_login, _sign_state(None, "login", provider)) async def _callback(provider: str, request: Request, user, session, user_manager, access_token_db) -> RedirectResponse: p = _provider_or_404(provider) if not enabled(provider): return _account_redirect("unavailable", provider) # User declined on the provider's screen, or the state doesn't check out. code = request.query_params.get("code") state = request.query_params.get("state", "") if not code: return _account_redirect("cancelled", provider) # Which flow minted this state? Purpose and provider are both inside the HMAC, # so a state only verifies under the flow and provider it was signed for. uid = _verify_state(state, "link", provider) if uid is not None: return await _finish_link(p, request, code, uid, user, session) if _verify_state(state, "login", provider) is not None: return await _finish_login(p, request, code, session, user_manager, access_token_db) return _account_redirect("error", provider) @router.get("/oauth/{provider}/callback") async def oauth_callback( provider: str, request: Request, user=Depends(current_user_optional), session: AsyncSession = Depends(get_async_session), user_manager=Depends(get_user_manager), access_token_db=Depends(get_access_token_db), ): return await _callback(provider, request, user, session, user_manager, access_token_db) async def _finish_link(p: Provider, request: Request, code: str, uid: str, user, session: AsyncSession) -> RedirectResponse: # Linking always acts on the person actually logged in, and only on the one the # state was minted for. if user is None or str(user.id) != uid: return _account_redirect("error", p.name) profile = await _fetch_profile(request, p, code) if profile is None: return _account_redirect("error", p.name) subject, email, _ = p.parse(profile) if not subject: return _account_redirect("error", p.name) # (provider, subject) is UNIQUE, so without this the insert would raise an # IntegrityError and 500 instead of explaining itself. owner = await _account_for(session, p.name, subject) if owner is not None: return _account_redirect("linked" if owner.user_id == user.id else "taken", p.name) # And (user_id, provider) is UNIQUE: this account already has a different # identity from this provider attached. existing = (await session.execute( select(OAuthAccount).where(OAuthAccount.user_id == user.id, OAuthAccount.provider == p.name) )).scalar_one_or_none() if existing is not None: return _account_redirect("already", p.name) await _link_row(session, user.id, p.name, subject, email) return _account_redirect("linked", p.name) async def _finish_login(p: Provider, request: Request, code: str, session: AsyncSession, user_manager, access_token_db) -> RedirectResponse: """Resolve the external identity to an account and sign in as it. Deliberately ignores any session already present. A login state carries no user id — it can't, there is no user yet — so it is replayable against whoever happens to be signed in. Treating it as a link would therefore be a forced-linking takeover: an attacker who gets a victim to open this callback with a code from the *attacker's* provider account would attach their identity to the victim's account, then sign in as them. Only link/start, whose state is bound to a specific user id, may link. The worst a replayed login state can do is sign someone into the attacker's own account. """ profile = await _fetch_profile(request, p, code) if profile is None: return _account_redirect("error", p.name) subject, email, verified = p.parse(profile) if not subject: return _account_redirect("error", p.name) created = False acct = await _account_for(session, p.name, subject) if acct is not None: user = await user_manager.user_db.get(acct.user_id) if user is None: return _account_redirect("error", p.name) else: # Nothing carries this identity, so fall back to the email — the only other # identity the provider gives us, and only when it vouches for it. if not email: return _account_redirect("noemail", p.name) if not verified: return _account_redirect("unverified", p.name) user = await user_manager.user_db.get_by_email(email) if user is None: user = await _create_user(user_manager, email) await _link_row(session, user.id, p.name, subject, email) audit.log_activity("auth.register", {"user_id": str(user.id), "via": p.name}) created = True elif not user.is_active: # Checked before the link, not just before the session: a deactivated # account must not quietly acquire an identity on the way out. return _account_redirect("inactive", p.name) else: # That address belongs to an account already tied to a different # identity from this provider; connecting would silently move it. existing = (await session.execute( select(OAuthAccount).where(OAuthAccount.user_id == user.id, OAuthAccount.provider == p.name) )).scalar_one_or_none() if existing is not None: return _account_redirect("mismatch", p.name) await _link_row(session, user.id, p.name, subject, email) if not user.is_active: return _account_redirect("inactive", p.name) response = _account_redirect("created" if created else "signedin", p.name) return await attach_session(response, user, user_manager, access_token_db, request) async def _unlink(provider: str, user, session: AsyncSession) -> None: p = _provider_or_404(provider) linked = (await session.execute( select(func.count()).select_from(OAuthAccount) .where(OAuthAccount.user_id == user.id) )).scalar_one() mine = await session.execute( select(OAuthAccount).where(OAuthAccount.user_id == user.id, OAuthAccount.provider == p.name)) if mine.scalar_one_or_none() is None: return # already unlinked; idempotent if user.oauth_only and linked <= 1: # No password anyone has ever seen, and this is the last way in. No # reset-password route is mounted to recover from it either. raise HTTPException( status_code=409, detail=f"This account was created with {p.label} and has no password, " "so unlinking would leave no way to sign in.", ) await session.execute( delete(OAuthAccount).where(OAuthAccount.user_id == user.id, OAuthAccount.provider == p.name)) if p.name == "discord": await session.execute( update(User).where(User.id == user.id) .values(discord_id=None, discord_dm=False)) await session.commit() @router.post("/oauth/{provider}/unlink", status_code=204) async def unlink( provider: str, user=Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ): await _unlink(provider, user, session)