thermograph/backend/accounts/oauth.py
Emi Griffith c17a4c3dd7
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m29s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine
Adds Google as a second identity provider. Rather than a second copy of the
Discord flow, the flow itself moves to accounts/oauth.py and both providers
become configurations of it — account resolution is the security-critical
part, and a parallel hand-rolled copy is where a subtle divergence becomes a
takeover. A third provider is now a PROVIDERS entry and nothing else.

Identity moves to an oauth_account table keyed (provider, subject), so a
login resolves on the provider's own stable id rather than an email that can
be changed or reassigned. user.discord_id deliberately stays: it is the DM
delivery address notify.py reads, not merely an identity, and the Discord
link keeps writing it. discord_only becomes oauth_only, and the lockout guard
now refuses to unlink the *last* provider from a password-less account rather
than singling out Discord — with two linked, either may go.

Migration 0005 renames the flag and backfills an oauth_account row for every
existing discord_id. Without that backfill an already-linked user would stop
being recognised at login and would silently get a second account on their
next sign-in. It also drops a vestigial discord_only that 0003 re-adds on a
database whose 0001 already built oauth_only from current metadata, which
otherwise left fresh and upgraded databases with different schemas.

Compatibility, since the frontend deploys independently of this service:
/discord/link/callback keeps answering because that URL is registered in
Discord's developer portal, the other /discord/* routes stay because the
deployed frontend calls them, the callback still emits ?discord= alongside
?oauth=, and UserRead still carries discord_only as a computed alias.

Google needs THERMOGRAPH_GOOGLE_CLIENT_ID/_CLIENT_SECRET and its own
registered redirect URI; unset, it reports disabled and shows no UI.
2026-07-26 12:06:56 -07:00

528 lines
23 KiB
Python

"""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)