accounts: sign in with Google, on a shared provider-agnostic OAuth engine
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

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.
This commit is contained in:
Emi Griffith 2026-07-26 12:06:56 -07:00
parent 3ec72ca75b
commit c17a4c3dd7
13 changed files with 1333 additions and 757 deletions

View file

@ -24,7 +24,7 @@ from sqlalchemy import (
UniqueConstraint,
false,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from accounts.db import Base
@ -41,13 +41,61 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# opt-in); the user can mute it while staying linked. Same migration caveat.
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
server_default=false())
# True when the account was created by "Sign in with Discord" and so has no
# password its owner has ever seen (hashed_password is NOT NULL, so the row
# carries a generated one nobody knows). Load-bearing: unlinking Discord from
# such an account would remove its only way in, so discord_link.py refuses.
discord_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
# True when the account was created by signing in with an OAuth provider and so
# has no password its owner has ever seen (hashed_password is NOT NULL, so the
# row carries a generated one nobody knows). Load-bearing: unlinking the *last*
# linked provider from such an account would remove its only way in, so
# accounts/oauth.py refuses that. Was `discord_only` before Google was added.
oauth_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
server_default=false())
# selectin, not the lazy default: these are read while serialising /users/me on
# the async engine, where a lazy load raises MissingGreenlet rather than
# quietly issuing a query. One extra SELECT per user load is the price.
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
"OAuthAccount", lazy="selectin", cascade="all, delete-orphan",
)
@property
def oauth_providers(self) -> list[str]:
"""Linked provider names — read straight onto UserRead by from_attributes."""
return sorted(a.provider for a in self.oauth_accounts)
class OAuthAccount(Base):
"""One external identity — a provider plus that provider's own user id — bound
to a Thermograph account. This is what "sign in with X" resolves against.
Keyed on ``subject``, never email: the provider's id for an account is stable,
while an email address can be changed or reassigned. Email is stored only for
support and debugging, and is deliberately not used for lookup.
Note ``User.discord_id`` is *not* redundant with a discord row here. That column
is a delivery address notify.py DMs it and survives on its own terms; this
table is purely about authentication.
"""
__tablename__ = "oauth_account"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
# "sub" for Google, "id" for Discord.
subject: Mapped[str] = mapped_column(String(64), nullable=False)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
__table_args__ = (
# A provider account signs into exactly one Thermograph account — this is
# the constraint that stops one Google login resolving two ways.
UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"),
# And an account holds at most one identity per provider, so "connect
# Google" is idempotent rather than accumulating rows.
UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"),
Index("idx_oauth_user", "user_id"),
)
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
# Inherits token (PK), user_id (FK -> user.id), created_at. Rows here ARE the

528
backend/accounts/oauth.py Normal file
View file

@ -0,0 +1,528 @@
"""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)

View file

@ -8,7 +8,7 @@ import uuid
from typing import Literal
from fastapi_users import schemas
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, computed_field, field_validator
from data import grading
@ -26,12 +26,24 @@ BOOKMARK_IMPORT_MAX_ITEMS = 200
class UserRead(schemas.BaseUser[uuid.UUID]):
display_name: str | None = None
# Present so the frontend can show linked/unlinked state; set via the OAuth
# flow (discord_link.py), not editable through the profile.
# flow (accounts/oauth.py), not editable through the profile.
discord_id: str | None = None
discord_dm: bool = False
# True when Discord is the account's only credential, so the UI can explain
# why "Unlink Discord" is refused rather than just failing.
discord_only: bool = False
# True when an OAuth provider is the account's only credential, so the UI can
# explain why unlinking the last one is refused rather than just failing.
oauth_only: bool = False
# Provider names currently linked, e.g. ["discord", "google"] — what the
# account menu renders its connected-accounts section from.
oauth_providers: list[str] = []
# Superseded by oauth_only. Kept because frontend and backend deploy
# independently: the frontend in production reads this field, and dropping it
# would break its account menu the moment this backend shipped ahead. Remove
# once no deployed frontend predates oauth_only.
@computed_field
@property
def discord_only(self) -> bool:
return self.oauth_only
class UserCreate(schemas.BaseUserCreate):

View file

@ -0,0 +1,104 @@
"""oauth_account table; user.discord_only -> user.oauth_only
Generalises "sign in with Discord" to any provider (see accounts/oauth.py). Three
steps, each conditional so the revision is safe on a database created before or
after the ORM gained these definitions revision 0001 builds the schema with
``Base.metadata.create_all`` off *live* model metadata, so a freshly created
database already has both the table and the renamed column, and an unconditional
DDL would fail there with duplicate-object errors.
The backfill is the load-bearing step. Discord identities used to live in
``user.discord_id``, and account resolution now keys on ``oauth_account``; without
copying the existing rows across, every already-linked user would silently stop
being recognised at login and would get a *second* account created on their next
sign-in. ``user.discord_id`` is deliberately left in place it is the DM delivery
address notify.py reads, not merely an identity.
Revision ID: 0005_oauth_accounts
Revises: 0004_bookmarks
Create Date: 2026-07-26
"""
import time
import sqlalchemy as sa
from alembic import op
# The same UUID type the ORM uses for user.id — a plain sa.Uuid renders differently
# on SQLite and would not match the column create_all produces.
from fastapi_users_db_sqlalchemy.generics import GUID
revision = "0005_oauth_accounts"
down_revision = "0004_bookmarks"
branch_labels = None
depends_on = None
def _columns(table: str) -> set[str]:
return {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def upgrade() -> None:
cols = _columns("user")
# 1. Rename the flag. It no longer means "Discord created this account", it
# means "no password its owner has ever seen, whichever provider made it".
if "oauth_only" in cols:
# A database built by 0001 already has oauth_only (create_all reads *current*
# metadata), and 0003 then re-added an empty discord_only beside it, because
# 0003 tests for the name it knew. Drop that vestige, or a fresh database
# carries a column an upgraded one does not — schema drift that would only
# surface much later. Safe: on this path the column was created empty
# moments ago and nothing has ever read it.
if "discord_only" in cols:
op.drop_column("user", "discord_only")
elif "discord_only" in cols:
# The real upgrade path: a deployed database carrying live values. Renamed,
# never dropped, so nothing is lost.
op.alter_column("user", "discord_only", new_column_name="oauth_only")
else:
# Database predates even discord_only (never deployed with it).
op.add_column("user", sa.Column("oauth_only", sa.Boolean(), nullable=False,
server_default=sa.false()))
# 2. The identity table.
if "oauth_account" not in _tables():
op.create_table(
"oauth_account",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", GUID(), sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False),
sa.Column("provider", sa.String(length=32), nullable=False),
sa.Column("subject", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=320), nullable=True),
sa.Column("created_at", sa.Float(), nullable=False),
sa.UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"),
sa.UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"),
)
op.create_index("idx_oauth_user", "oauth_account", ["user_id"])
# 3. Backfill every existing Discord link. Guarded by NOT EXISTS so re-running
# (or running after create_all already produced the table) cannot violate
# either unique constraint.
op.execute(sa.text("""
INSERT INTO oauth_account (user_id, provider, subject, email, created_at)
SELECT u.id, 'discord', u.discord_id, u.email, :now
FROM "user" u
WHERE u.discord_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM oauth_account o
WHERE o.provider = 'discord'
AND (o.subject = u.discord_id OR o.user_id = u.id)
)
""").bindparams(now=time.time()))
def downgrade() -> None:
if "oauth_account" in _tables():
op.drop_index("idx_oauth_user", table_name="oauth_account")
op.drop_table("oauth_account")
cols = _columns("user")
if "oauth_only" in cols and "discord_only" not in cols:
op.alter_column("user", "oauth_only", new_column_name="discord_only")

View file

@ -1,243 +1,54 @@
"""Discord as an identity: sign in with it, and connect it to an account.
"""Discord's legacy /discord/* auth routes, plus the DM opt-in toggle.
Two flows over one authorization-code grant, told apart by the signed `state`:
The OAuth flow itself moved to ``accounts/oauth.py`` when Google was added this
module holds no auth logic, only the paths that have to keep answering:
link /discord/link/start an already-signed-in user attaches a Discord
account to the session they're in
login /discord/login/start an anonymous visitor authenticates, creating or
resolving a Thermograph account from the Discord
identity
* **/discord/link/callback is registered in Discord's developer portal.** Changing
it would need an operator to edit the portal before any Discord login worked, so
it stays exactly where it is and forwards to the shared handler.
* The other /discord/* routes are what the currently-deployed frontend calls.
Frontend and backend deploy independently (see the root CLAUDE.md), so a newer
backend must keep serving an older frontend. They can go once no deployed
frontend predates /oauth/*.
**Both come back to /discord/link/callback.** Discord only honours redirect URIs
pre-registered in the developer portal, so a second callback path would mean an
operator has to add one there before login could work anywhere. Sharing the
registered one keeps this deployable with no portal change; the flows separate on
the state's purpose, which is inside the HMAC and so can't be flipped by hand.
`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
carries the purpose plus, for a link, the Thermograph user id so a callback can't
be replayed, bound to a different account, or replayed *as the other flow*.
How a login resolves to an account, in order:
1. A user already carrying this `discord_id` the durable key, no email needed.
2. Otherwise the Discord email, but **only if Discord 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. Unverified is refused outright.
3. No account for that email: create one. It has no password its owner has ever
seen, so it is flagged `discord_only` and unlink is refused (see `unlink`)
otherwise unlinking would delete the only way back in.
``POST /discord/dm`` is not legacy and is not moving: it toggles alert delivery,
which is a notifications concern, not an authentication one.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from accounts import oauth
from accounts.db import get_async_session
from accounts.models import 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=["discord"])
CLIENT_ID = os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip()
CLIENT_SECRET = os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()
BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/")
_AUTHORIZE = "https://discord.com/api/oauth2/authorize"
_TOKEN = "https://discord.com/api/oauth2/token"
_ME = "https://discord.com/api/users/@me"
# Linking only needs the account id. Signing in also needs the address to match or
# create a Thermograph account by, which is a separate scope Discord prompts for.
_SCOPE_LINK = "identify"
_SCOPE_LOGIN = "identify email"
# How long a start->callback round trip may take before the signed state expires.
_STATE_TTL = 600
_TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool:
return bool(CLIENT_ID and CLIENT_SECRET)
# --- 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 = "link") -> str:
payload = _b64(json.dumps(
{"u": uid or "", "t": int(time.time()), "p": purpose}
).encode())
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
return f"{payload}.{mac}"
def _verify_state(state: str, purpose: str = "link") -> 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.
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
# States minted before the purpose field existed are links; they age out in
# _STATE_TTL, so this fallback only spans a deploy.
if data.get("p", "link") != purpose:
return None
return data.get("u") or ""
def _redirect_uri(request: Request) -> str:
"""The callback URL, matched to how the app is actually reached (scheme/host +
base path). Must equal the redirect registered in the Discord portal."""
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}/api/v2/discord/link/callback"
def _authorize_redirect(request: Request, scope: str, state: str) -> RedirectResponse:
params = {
"client_id": CLIENT_ID,
"redirect_uri": _redirect_uri(request),
"response_type": "code",
"scope": scope,
"state": state,
"prompt": "consent",
}
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}",
status_code=303)
def _account_redirect(status: str) -> RedirectResponse:
# Back to the alerts page, which surfaces the outcome as a toast; 303 so the
# browser issues a GET after the callback. The frontend serves this page at
# /alerts — the route is named for the feature, not for subscriptions.html.
return RedirectResponse(url=f"{BASE}/alerts?discord={status}", status_code=303)
# --- Discord API --------------------------------------------------------------
async def _fetch_profile(request: Request, code: str) -> dict | None:
"""Trade the authorization code for the Discord user object, or None if any
step fails. Callers treat None as a graceful error, never a crash."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
tok = await client.post(_TOKEN, data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(request),
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
if tok.status_code >= 300:
return None
access = tok.json().get("access_token")
me = await client.get(_ME, 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
async def _user_by_discord_id(session: AsyncSession, discord_id: str) -> User | None:
return (
await session.execute(select(User).where(User.discord_id == discord_id))
).scalar_one_or_none()
async def _create_from_discord(user_manager, email: str, discord_id: str) -> User:
"""Create an account for a Discord 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 Discord has already verified. The generated password exists only
because ``hashed_password`` is NOT NULL nobody ever sees it, which is what
``discord_only`` records.
"""
password = user_manager.password_helper.generate()
user = 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,
"discord_id": discord_id,
"discord_dm": True,
"discord_only": True,
})
audit.log_activity("auth.register", {"user_id": str(user.id), "via": "discord"})
return user
# --- routes ------------------------------------------------------------------
@router.get("/discord/config")
async def discord_config():
"""Whether Discord is configured on this server. The account menu and the sign-in
modal use it to offer Discord only when it will actually work so an
unconfigured server surfaces no dead-end Discord UI, and enabling it later
(setting the OAuth env vars) makes both entries appear on their own."""
return {"enabled": enabled()}
"""Superseded by /oauth/config, which reports every provider. Kept because the
deployed frontend asks this one."""
return {"enabled": oauth.enabled("discord")}
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
if not enabled():
return _account_redirect("unavailable")
return _authorize_redirect(request, _SCOPE_LINK, _sign_state(str(user.id), "link"))
return await oauth.link_start("discord", request, user)
@router.get("/discord/login/start")
async def login_start(request: Request, user=Depends(current_user_optional)):
"""Sign in with Discord. Unauthenticated by design — this is how you get a
session, not something you do with one."""
if not enabled():
return _account_redirect("unavailable")
# Someone already signed in who lands here wants to connect, not to be
# switched into whichever account the Discord identity resolves to.
if user is not None:
return _authorize_redirect(request, _SCOPE_LINK, _sign_state(str(user.id), "link"))
return _authorize_redirect(request, _SCOPE_LOGIN, _sign_state(None, "login"))
return await oauth.login_start("discord", request, user)
@router.get("/discord/link/callback")
@ -248,103 +59,9 @@ async def link_callback(
user_manager=Depends(get_user_manager),
access_token_db=Depends(get_access_token_db),
):
"""The single redirect target both flows return to (see the module docstring)."""
if not enabled():
return _account_redirect("unavailable")
# User declined on Discord'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")
# Which flow minted this state? The purpose is inside the HMAC, so a state
# can only verify under the one it was signed for.
uid = _verify_state(state, "link")
if uid is not None:
return await _finish_link(request, code, uid, user, session)
if _verify_state(state, "login") is not None:
return await _finish_login(request, code, session, user_manager, access_token_db)
return _account_redirect("error")
async def _finish_link(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")
profile = await _fetch_profile(request, code)
if profile is None:
return _account_redirect("error")
discord_id = str(profile.get("id") or "")
if not discord_id:
return _account_redirect("error")
# discord_id is UNIQUE, so without this check the UPDATE would raise an
# IntegrityError and 500 instead of explaining itself.
owner = await _user_by_discord_id(session, discord_id)
if owner is not None and owner.id != user.id:
return _account_redirect("taken")
# Linking is an active opt-in, so turn DM alerts on; the user can mute them
# (POST /discord/dm) while staying linked.
await session.execute(
update(User).where(User.id == user.id).values(discord_id=discord_id, discord_dm=True)
)
await session.commit()
return _account_redirect("linked")
async def _finish_login(request: Request, code: str, session: AsyncSession,
user_manager, access_token_db) -> RedirectResponse:
"""Resolve the Discord 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* Discord would attach their identity to the victim's
account, then sign in as them. Only /discord/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, code)
if profile is None:
return _account_redirect("error")
discord_id = str(profile.get("id") or "")
if not discord_id:
return _account_redirect("error")
created = False
user = await _user_by_discord_id(session, discord_id)
if user is None:
# No account carries this Discord id, so fall back to the email — the only
# other identity Discord gives us, and only when Discord vouches for it.
email = str(profile.get("email") or "").strip().lower()
if not email:
return _account_redirect("noemail")
if profile.get("verified") is not True:
return _account_redirect("unverified")
user = await user_manager.user_db.get_by_email(email)
if user is None:
user = await _create_from_discord(user_manager, email, discord_id)
created = True
elif user.discord_id:
# That address belongs to an account already tied to a different
# Discord identity; connecting would silently move it.
return _account_redirect("mismatch")
elif not user.is_active:
# Checked before the link, not just before the session: a deactivated
# account must not quietly acquire a Discord identity on the way out.
return _account_redirect("inactive")
else:
await session.execute(
update(User).where(User.id == user.id)
.values(discord_id=discord_id, discord_dm=True)
)
await session.commit()
if not user.is_active:
return _account_redirect("inactive")
response = _account_redirect("created" if created else "signedin")
return await attach_session(response, user, user_manager, access_token_db, request)
"""The URL registered in Discord's portal. Both Discord flows land here."""
return await oauth._callback("discord", request, user, session, user_manager,
access_token_db)
@router.post("/discord/unlink", status_code=204)
@ -352,18 +69,7 @@ async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
if user.discord_only:
# The account has no password anyone has ever seen, and no reset-password
# route is mounted to set one — unlinking here is unrecoverable.
raise HTTPException(
status_code=409,
detail="This account was created with Discord and has no password, "
"so unlinking would leave no way to sign in.",
)
await session.execute(
update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
)
await session.commit()
await oauth._unlink("discord", user, session)
class DmToggle(BaseModel):

View file

@ -0,0 +1,351 @@
"""Sign in with an external provider: account resolution, session issuance, and the
guards that keep the login flow from becoming an account-takeover path.
Parametrised over every configured provider, so Discord and Google are held to the
same rules rather than one being tested and the other assumed. Provider HTTP is
mocked; reuses the throwaway accounts DB from conftest.
"""
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db, oauth
V2 = "/thermograph/api/v2"
PW = "supersecret123"
# (provider, callback path, profile builder). Discord's callback is grandfathered
# to the URL registered in its developer portal; anything newer uses the canonical
# /oauth/{provider}/callback.
PROVIDERS = [
("discord", f"{V2}/discord/link/callback",
lambda sub, email, ver: {"id": sub, "email": email, "verified": ver}),
("google", f"{V2}/oauth/google/callback",
lambda sub, email, ver: {"sub": sub, "email": email, "email_verified": ver}),
]
IDS = [p[0] for p in PROVIDERS]
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
for name in oauth.PROVIDERS:
monkeypatch.setitem(oauth.CREDENTIALS, name, (f"{name}-id", f"{name}-secret"))
@pytest.fixture(params=PROVIDERS, ids=IDS)
def prov(request):
"""A provider under test: .name, .callback, .profile(sub, email, verified)."""
name, callback, builder = request.param
class P:
pass
p = P()
p.name, p.callback, p.profile = name, callback, builder
return p
class _Resp:
def __init__(self, code, data): self.status_code = code; self._data = data
def json(self): return self._data
def _mock(monkeypatch, profile):
"""Stand in for httpx.AsyncClient: token exchange, then userinfo -> profile."""
class _Client:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
async def get(self, url, **k): return _Resp(200, profile)
monkeypatch.setattr(oauth.httpx, "AsyncClient", _Client)
def _register(client, email):
assert client.post(f"{V2}/auth/register",
json={"email": email, "password": PW}).status_code in (201, 400)
def _login(client, email):
_register(client, email)
assert client.post(f"{V2}/auth/login",
data={"username": email, "password": PW}).status_code == 204
def _cb(client, prov, state):
return client.get(f"{prov.callback}?code=abc&state={state}", follow_redirects=False)
def _me(client):
return client.get(f"{V2}/users/me").json()
# --- config -------------------------------------------------------------------
def test_config_lists_every_provider_and_its_enabled_state(monkeypatch):
c = TestClient(appmod.app)
monkeypatch.setitem(oauth.CREDENTIALS, "google", ("", ""))
by_name = {p["name"]: p for p in c.get(f"{V2}/oauth/config").json()["providers"]}
assert by_name["discord"]["enabled"] is True
assert by_name["google"]["enabled"] is False # half-configured is inert
assert by_name["google"]["label"] == "Google"
def test_unknown_provider_is_404():
c = TestClient(appmod.app)
assert c.get(f"{V2}/oauth/gitlab/login/start",
follow_redirects=False).status_code == 404
# --- start --------------------------------------------------------------------
def test_login_start_needs_no_session_and_asks_for_an_email_scope(prov):
c = TestClient(appmod.app)
r = c.get(f"{V2}/oauth/{prov.name}/login/start", follow_redirects=False)
assert r.status_code == 303
loc = r.headers["location"]
assert loc.startswith(oauth.PROVIDERS[prov.name].authorize_url)
# Whatever the provider calls it, the login scope must be able to yield an
# address — without one there is no account to resolve or create.
assert "email" in loc and "state=" in loc
def test_login_start_while_signed_in_links_instead_of_switching_account(prov):
c = TestClient(appmod.app)
_login(c, f"already-in-{prov.name}@example.com")
r = c.get(f"{V2}/oauth/{prov.name}/login/start", follow_redirects=False)
state = r.headers["location"].split("state=")[1].split("&")[0]
assert oauth._verify_state(state, "link", prov.name) == _me(c)["id"]
def test_start_when_unconfigured_bounces_back(prov, monkeypatch):
monkeypatch.setitem(oauth.CREDENTIALS, prov.name, ("", ""))
c = TestClient(appmod.app)
r = c.get(f"{V2}/oauth/{prov.name}/login/start", follow_redirects=False)
assert r.status_code == 303 and "oauth=unavailable" in r.headers["location"]
# --- signed state -------------------------------------------------------------
def test_state_purposes_and_providers_do_not_cross_over():
login = oauth._sign_state(None, "login", "google")
link = oauth._sign_state("user-123", "link", "google")
# A login state carries no user id but verifies as "" — distinct from reject.
assert oauth._verify_state(login, "login", "google") == ""
assert oauth._verify_state(login, "link", "google") is None
assert oauth._verify_state(link, "link", "google") == "user-123"
assert oauth._verify_state(link, "login", "google") is None
# And a state minted for one provider is worthless at another's callback.
assert oauth._verify_state(link, "link", "discord") is None
assert oauth._verify_state(login, "login", "discord") is None
def test_state_rejects_tampering_and_expiry(monkeypatch):
s = oauth._sign_state("u", "link", "google")
payload, mac = s.split(".", 1)
assert oauth._verify_state(f"{payload}.{'0' * len(mac)}", "link", "google") is None
assert oauth._verify_state(payload, "link", "google") is None
assert oauth._verify_state("garbage", "link", "google") is None
monkeypatch.setattr(oauth, "_STATE_TTL", -1)
assert oauth._verify_state(oauth._sign_state("u", "link", "google"),
"link", "google") is None
def test_state_is_secret_dependent(monkeypatch):
s = oauth._sign_state("u", "link", "google")
monkeypatch.setattr(oauth, "SECRET", oauth.SECRET + "-different")
assert oauth._verify_state(s, "link", "google") is None
def test_a_login_state_cannot_link_the_signed_in_account(prov, monkeypatch):
"""Forced-linking guard. A login state carries no user id, so it is replayable
against whoever is signed in; if the callback treated it as a link, an attacker
could attach their own identity to a victim's account and then sign in as them.
It must complete as a plain login instead."""
attacker = f"attacker-{prov.name}@example.com"
_mock(monkeypatch, prov.profile(f"{prov.name}-cross", attacker, True))
c = TestClient(appmod.app)
_login(c, f"crossover-{prov.name}@example.com")
r = _cb(c, prov, oauth._sign_state(_me(c)["id"], "login", prov.name))
assert "oauth=created" in r.headers["location"]
assert _me(c)["email"] == attacker
# The account that had been signed in is untouched.
victim = TestClient(appmod.app)
_login(victim, f"crossover-{prov.name}@example.com")
assert _me(victim)["oauth_providers"] == []
# --- login resolves an account ------------------------------------------------
def test_login_creates_an_account_and_signs_in(prov, monkeypatch):
# Mixed case on purpose: the address must be normalised before it is matched or
# stored, or the same person gets two accounts depending on how they typed it.
_mock(monkeypatch, prov.profile(f"{prov.name}-new",
f"New.User.{prov.name}@Example.com", True))
c = TestClient(appmod.app)
r = _cb(c, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=created" in r.headers["location"]
me = _me(c)
assert me["email"] == f"new.user.{prov.name}@example.com" # normalised
assert me["is_verified"] is True # the provider vouched
assert me["oauth_only"] is True
assert me["oauth_providers"] == [prov.name]
def test_login_recognises_a_previously_linked_account(prov, monkeypatch):
"""The subject is the durable key: it resolves the account with no email, which
is what keeps a login working after someone changes their address upstream."""
email = f"link-me-{prov.name}@example.com"
_mock(monkeypatch, prov.profile(f"{prov.name}-ret", email, True))
linker = TestClient(appmod.app)
_login(linker, email)
uid = _me(linker)["id"]
assert "oauth=linked" in _cb(
linker, prov, oauth._sign_state(uid, "link", prov.name)).headers["location"]
# A fresh client, no session, and the provider now returns no email at all.
_mock(monkeypatch, prov.profile(f"{prov.name}-ret", "", False))
c = TestClient(appmod.app)
r = _cb(c, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=signedin" in r.headers["location"]
assert _me(c)["id"] == uid
def test_login_connects_a_verified_email_to_an_existing_account(prov, monkeypatch):
"""The 'connect the account' path: an existing password account picks up the
identity and is signed in."""
email = f"existing-{prov.name}@example.com"
c = TestClient(appmod.app)
_register(c, email)
_mock(monkeypatch, prov.profile(f"{prov.name}-exist", email, True))
fresh = TestClient(appmod.app)
r = _cb(fresh, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=signedin" in r.headers["location"]
me = _me(fresh)
assert me["email"] == email and me["oauth_providers"] == [prov.name]
# It kept its password, so it is not OAuth-only and may unlink.
assert me["oauth_only"] is False
def test_login_refuses_an_unverified_email(prov, monkeypatch):
"""The provider's verified flag is the only evidence the person owns the
address. Without it, this would hand over any account whose email was typed in."""
email = f"victim-{prov.name}@example.com"
c = TestClient(appmod.app)
_register(c, email)
_mock(monkeypatch, prov.profile(f"{prov.name}-attacker", email, False))
fresh = TestClient(appmod.app)
r = _cb(fresh, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=unverified" in r.headers["location"]
assert fresh.get(f"{V2}/users/me").status_code == 401 # no session issued
_login(c, email)
assert _me(c)["oauth_providers"] == [] # target untouched
def test_login_without_an_email_cannot_create_an_account(prov, monkeypatch):
_mock(monkeypatch, prov.profile(f"{prov.name}-noemail", "", True))
c = TestClient(appmod.app)
r = _cb(c, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=noemail" in r.headers["location"]
assert c.get(f"{V2}/users/me").status_code == 401
def test_login_will_not_move_an_account_between_identities(prov, monkeypatch):
email = f"owned-{prov.name}@example.com"
owner = TestClient(appmod.app)
_login(owner, email)
uid = _me(owner)["id"]
_mock(monkeypatch, prov.profile(f"{prov.name}-owner", email, True))
assert "oauth=linked" in _cb(
owner, prov, oauth._sign_state(uid, "link", prov.name)).headers["location"]
# A second provider account claiming the same address.
_mock(monkeypatch, prov.profile(f"{prov.name}-interloper", email, True))
fresh = TestClient(appmod.app)
r = _cb(fresh, prov, oauth._sign_state(None, "login", prov.name))
assert r.status_code == 303 and "oauth=mismatch" in r.headers["location"]
assert fresh.get(f"{V2}/users/me").status_code == 401
def test_linking_an_identity_someone_else_owns_is_refused(prov, monkeypatch):
first = TestClient(appmod.app)
_login(first, f"first-owner-{prov.name}@example.com")
_mock(monkeypatch, prov.profile(f"{prov.name}-contested",
f"first-owner-{prov.name}@example.com", True))
assert "oauth=linked" in _cb(
first, prov,
oauth._sign_state(_me(first)["id"], "link", prov.name)).headers["location"]
second = TestClient(appmod.app)
_login(second, f"second-owner-{prov.name}@example.com")
r = _cb(second, prov, oauth._sign_state(_me(second)["id"], "link", prov.name))
assert r.status_code == 303 and "oauth=taken" in r.headers["location"]
assert _me(second)["oauth_providers"] == []
def test_relinking_the_same_identity_is_idempotent(prov, monkeypatch):
email = f"relink-{prov.name}@example.com"
c = TestClient(appmod.app)
_login(c, email)
_mock(monkeypatch, prov.profile(f"{prov.name}-relink", email, True))
uid = _me(c)["id"]
for _ in range(2):
r = _cb(c, prov, oauth._sign_state(uid, "link", prov.name))
assert "oauth=linked" in r.headers["location"]
assert _me(c)["oauth_providers"] == [prov.name] # not duplicated
# --- unlink guards ------------------------------------------------------------
def test_last_provider_cannot_be_unlinked_into_a_lockout(prov, monkeypatch):
_mock(monkeypatch, prov.profile(f"{prov.name}-onlyway",
f"onlyway-{prov.name}@example.com", True))
c = TestClient(appmod.app)
assert "oauth=created" in _cb(
c, prov, oauth._sign_state(None, "login", prov.name)).headers["location"]
r = c.post(f"{V2}/oauth/{prov.name}/unlink")
assert r.status_code == 409 and "no password" in r.json()["detail"]
assert _me(c)["oauth_providers"] == [prov.name]
def test_an_oauth_only_account_may_unlink_once_a_second_provider_is_linked(monkeypatch):
"""The lockout rule is about the *last* credential, not about Discord. With two
providers linked, dropping one still leaves a way in."""
_mock(monkeypatch, {"sub": "g-two", "email": "two@example.com",
"email_verified": True})
c = TestClient(appmod.app)
assert "oauth=created" in _cb(
c, PROV_GOOGLE, oauth._sign_state(None, "login", "google")).headers["location"]
uid = _me(c)["id"]
# Now connect Discord to the same, still password-less, account.
_mock(monkeypatch, {"id": "d-two", "email": "two@example.com", "verified": True})
assert "oauth=linked" in _cb(
c, PROV_DISCORD, oauth._sign_state(uid, "link", "discord")).headers["location"]
assert _me(c)["oauth_providers"] == ["discord", "google"]
# Either one may now go.
assert c.post(f"{V2}/oauth/google/unlink").status_code == 204
assert _me(c)["oauth_providers"] == ["discord"]
# But the survivor is once again the only way in.
assert c.post(f"{V2}/oauth/discord/unlink").status_code == 409
def test_unlink_requires_auth(prov):
c = TestClient(appmod.app)
assert c.post(f"{V2}/oauth/{prov.name}/unlink").status_code == 401
def test_link_start_requires_auth(prov):
c = TestClient(appmod.app)
assert c.get(f"{V2}/oauth/{prov.name}/link/start",
follow_redirects=False).status_code == 401
# Bare provider handles for the cross-provider test above, which needs both at once.
class _P:
def __init__(self, name, callback):
self.name, self.callback = name, callback
PROV_GOOGLE = _P("google", f"{V2}/oauth/google/callback")
PROV_DISCORD = _P("discord", f"{V2}/discord/link/callback")

View file

@ -7,8 +7,8 @@ from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db
from accounts import oauth
from notifications import discord
from notifications import discord_link as dl
from notifications import notify
V2 = "/thermograph/api/v2"
@ -161,14 +161,15 @@ class _MockClient:
def test_linking_opts_in_and_toggle_flips(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app")
monkeypatch.setattr(dl, "CLIENT_SECRET", "sec")
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app", "sec"))
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
_login(c, "dm-toggle@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
# Link -> opted in by default.
r = c.get(f"{V2}/discord/link/callback?code=x&state={dl._sign_state(uid)}", follow_redirects=False)
# Link -> opted in by default. discord_id is a delivery address, so the link
# has to write it as well as the oauth_account row.
state = oauth._sign_state(uid, "link", "discord")
r = c.get(f"{V2}/discord/link/callback?code=x&state={state}", follow_redirects=False)
assert r.status_code == 303
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is True

View file

@ -1,11 +1,20 @@
"""Discord account linking: signed-state integrity, OAuth callback (Discord HTTP
mocked), unlink, and auth gating. Reuses the throwaway accounts DB from conftest."""
"""The legacy /discord/* routes.
These are not duplicate coverage of tests/accounts/test_oauth.py they pin the two
compatibility promises that outlive the refactor:
* **/discord/link/callback is registered in Discord's developer portal.** If it
stops answering, every Discord login breaks until an operator edits the portal.
* The other /discord/* paths are what the deployed frontend calls, and frontend and
backend deploy independently, so a newer backend must keep serving an older one.
The flow itself lives in accounts/oauth.py and is tested there.
"""
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db
from notifications import discord_link as dl
from accounts import db, oauth
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@ -16,72 +25,10 @@ def _tables():
db.Base.metadata.create_all(db.sync_engine)
def _login(client, email):
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
assert r.status_code in (201, 400)
assert client.post(f"{V2}/auth/login", data={"username": email, "password": PW}).status_code == 204
# --- signed state ------------------------------------------------------------
def test_state_round_trips_and_rejects_tampering():
s = dl._sign_state("user-123")
assert dl._verify_state(s) == "user-123"
payload, mac = s.split(".", 1)
assert dl._verify_state(f"{payload}.{'0' * len(mac)}") is None # bad mac
assert dl._verify_state(payload) is None # no mac
assert dl._verify_state("garbage") is None
def test_state_expires(monkeypatch):
monkeypatch.setattr(dl, "_STATE_TTL", -1) # already expired
assert dl._verify_state(dl._sign_state("u")) is None
def test_state_is_secret_dependent(monkeypatch):
s = dl._sign_state("u")
monkeypatch.setattr(dl, "SECRET", dl.SECRET + "-different")
assert dl._verify_state(s) is None # signed under the old secret
# --- routes ------------------------------------------------------------------
def test_config_reports_enabled_state(monkeypatch):
c = TestClient(appmod.app)
monkeypatch.setattr(dl, "CLIENT_ID", "")
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
r = c.get(f"{V2}/discord/config") # no auth required
assert r.status_code == 200 and r.json() == {"enabled": False}
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
assert c.get(f"{V2}/discord/config").json() == {"enabled": True}
def test_link_requires_auth():
c = TestClient(appmod.app)
assert c.get(f"{V2}/discord/link/start", follow_redirects=False).status_code == 401
assert c.post(f"{V2}/discord/unlink").status_code == 401
def test_start_redirects_to_discord(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
c = TestClient(appmod.app)
_login(c, "start@example.com")
r = c.get(f"{V2}/discord/link/start", follow_redirects=False)
assert r.status_code == 303
loc = r.headers["location"]
assert loc.startswith("https://discord.com/api/oauth2/authorize")
assert "client_id=app123" in loc and "scope=identify" in loc and "state=" in loc
def test_start_when_unconfigured_bounces_back(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "")
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
c = TestClient(appmod.app)
_login(c, "noconf@example.com")
r = c.get(f"{V2}/discord/link/start", follow_redirects=False)
assert r.status_code == 303 and "discord=unavailable" in r.headers["location"]
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app123", "secret456"))
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
class _Resp:
@ -90,47 +37,88 @@ class _Resp:
class _MockClient:
"""Stands in for httpx.AsyncClient: token exchange then /users/@me."""
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
async def get(self, url, **k): return _Resp(200, {"id": "discord-999", "username": "someone"})
async def get(self, url, **k):
return _Resp(200, {"id": "discord-999", "email": "legacy@example.com",
"verified": True})
def test_callback_links_the_account(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
def _login(client, email):
assert client.post(f"{V2}/auth/register",
json={"email": email, "password": PW}).status_code in (201, 400)
assert client.post(f"{V2}/auth/login",
data={"username": email, "password": PW}).status_code == 204
def test_legacy_config_still_reports_enabled_state(monkeypatch):
c = TestClient(appmod.app)
_login(c, "callback@example.com")
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("", ""))
assert c.get(f"{V2}/discord/config").json() == {"enabled": False}
monkeypatch.setitem(oauth.CREDENTIALS, "discord", ("app123", "secret456"))
assert c.get(f"{V2}/discord/config").json() == {"enabled": True}
def test_legacy_link_start_still_requires_auth():
c = TestClient(appmod.app)
assert c.get(f"{V2}/discord/link/start", follow_redirects=False).status_code == 401
assert c.post(f"{V2}/discord/unlink").status_code == 401
def test_legacy_start_redirects_to_discord():
c = TestClient(appmod.app)
_login(c, "legacy-start@example.com")
r = c.get(f"{V2}/discord/link/start", follow_redirects=False)
assert r.status_code == 303
loc = r.headers["location"]
assert loc.startswith("https://discord.com/api/oauth2/authorize")
assert "client_id=app123" in loc and "scope=identify" in loc and "state=" in loc
# The redirect_uri it asks Discord to call back on must be the registered one.
assert "discord%2Flink%2Fcallback" in loc
def test_the_registered_callback_url_still_completes_a_link():
c = TestClient(appmod.app)
_login(c, "legacy-cb@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
state = dl._sign_state(uid)
r = c.get(f"{V2}/discord/link/callback?code=abc&state={state}", follow_redirects=False)
assert r.status_code == 303 and "discord=linked" in r.headers["location"]
# /users/me now reports the linked id, and unlink clears it.
assert c.get(f"{V2}/users/me").json()["discord_id"] == "discord-999"
assert c.post(f"{V2}/discord/unlink").status_code == 204
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
def test_callback_rejects_a_forged_state(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
_login(c, "forged@example.com")
# State signed for a different user id must not link this session.
r = c.get(f"{V2}/discord/link/callback?code=abc&state={dl._sign_state('someone-else')}",
state = oauth._sign_state(uid, "link", "discord")
r = c.get(f"{V2}/discord/link/callback?code=abc&state={state}",
follow_redirects=False)
assert r.status_code == 303 and "discord=error" in r.headers["location"]
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
assert r.status_code == 303 and "discord=linked" in r.headers["location"]
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-999" # the DM delivery address
assert me["oauth_providers"] == ["discord"] # and the identity row
# Legacy unlink clears both.
assert c.post(f"{V2}/discord/unlink").status_code == 204
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] is None and me["oauth_providers"] == []
def test_callback_without_code_is_cancelled(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
def test_the_callback_still_emits_the_discord_query_param():
"""The deployed frontend reads ?discord=<status>, not ?oauth=. Dropping it would
silently stop it showing any outcome at all."""
c = TestClient(appmod.app)
_login(c, "cancel@example.com")
r = c.get(f"{V2}/discord/link/callback?error=access_denied", follow_redirects=False)
assert r.status_code == 303 and "discord=cancelled" in r.headers["location"]
loc = r.headers["location"]
assert "discord=cancelled" in loc and "oauth=cancelled" in loc
def test_users_me_still_carries_the_deprecated_discord_only_field(monkeypatch):
"""Renamed to oauth_only, but the deployed frontend still reads the old name."""
monkeypatch.setattr(oauth.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/link/callback?code=abc"
f"&state={oauth._sign_state(None, 'login', 'discord')}",
follow_redirects=False)
assert "discord=created" in r.headers["location"]
me = c.get(f"{V2}/users/me").json()
assert me["oauth_only"] is True and me["discord_only"] is True
def test_legacy_login_start_is_still_mounted():
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("https://discord.com/api/oauth2/authorize")

View file

@ -1,251 +0,0 @@
"""Sign in with Discord: account resolution, session issuance, and the guards that
keep the login flow from becoming an account-takeover path. Discord HTTP is mocked;
reuses the throwaway accounts DB from conftest."""
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from accounts import db
from notifications import discord_link as dl
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
@pytest.fixture(autouse=True)
def _configured(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app123")
monkeypatch.setattr(dl, "CLIENT_SECRET", "secret456")
class _Resp:
def __init__(self, code, data): self.status_code = code; self._data = data
def json(self): return self._data
def _mock_discord(monkeypatch, profile):
"""Stand in for httpx.AsyncClient: token exchange, then /users/@me -> profile."""
class _Client:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
async def get(self, url, **k): return _Resp(200, profile)
monkeypatch.setattr(dl.httpx, "AsyncClient", _Client)
def _register(client, email):
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
assert r.status_code in (201, 400)
def _login(client, email):
_register(client, email)
assert client.post(f"{V2}/auth/login",
data={"username": email, "password": PW}).status_code == 204
def _callback(client, state):
return client.get(f"{V2}/discord/link/callback?code=abc&state={state}",
follow_redirects=False)
# --- start -------------------------------------------------------------------
def test_login_start_needs_no_session_and_asks_for_email():
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
assert r.status_code == 303
loc = r.headers["location"]
assert loc.startswith("https://discord.com/api/oauth2/authorize")
# The email scope is what makes account resolution possible at all.
assert "scope=identify+email" in loc and "state=" in loc
def test_login_start_while_signed_in_links_instead_of_switching_account():
"""Someone already signed in wants to connect Discord, not be switched into
whichever account the Discord identity happens to resolve to."""
c = TestClient(appmod.app)
_login(c, "already-in@example.com")
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
assert r.status_code == 303
loc = r.headers["location"]
assert "scope=identify&" in loc or loc.endswith("scope=identify")
state = loc.split("state=")[1].split("&")[0]
uid = c.get(f"{V2}/users/me").json()["id"]
assert dl._verify_state(state, "link") == uid
def test_login_start_when_unconfigured_bounces_back(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "")
monkeypatch.setattr(dl, "CLIENT_SECRET", "")
c = TestClient(appmod.app)
r = c.get(f"{V2}/discord/login/start", follow_redirects=False)
assert r.status_code == 303 and "discord=unavailable" in r.headers["location"]
# --- state purposes are not interchangeable ----------------------------------
def test_state_purposes_do_not_cross_over():
login_state = dl._sign_state(None, "login")
link_state = dl._sign_state("user-123", "link")
# A login state carries no user id, but verifies as "" — distinct from reject.
assert dl._verify_state(login_state, "login") == ""
assert dl._verify_state(login_state, "link") is None
assert dl._verify_state(link_state, "link") == "user-123"
assert dl._verify_state(link_state, "login") is None
def test_a_login_state_cannot_link_the_signed_in_account(monkeypatch):
"""Forced-linking guard. A login state carries no user id, so it is replayable
against whoever is signed in; if the callback treated it as a link, an attacker
could attach their own Discord identity to a victim's account and then sign in
as them. It must complete as a plain login instead."""
_mock_discord(monkeypatch, {"id": "d-crossover", "email": "attacker@example.com",
"verified": True})
c = TestClient(appmod.app)
_login(c, "crossover@example.com")
# A state whose purpose says login, but naming the signed-in user — the shape
# a forced-link attempt would take.
r = _callback(c, dl._sign_state(c.get(f"{V2}/users/me").json()["id"], "login"))
# Resolved as a login into the Discord identity's own account, not as a link.
assert "discord=created" in r.headers["location"]
assert c.get(f"{V2}/users/me").json()["email"] == "attacker@example.com"
# The account that had been signed in is untouched.
victim = TestClient(appmod.app)
_login(victim, "crossover@example.com")
assert victim.get(f"{V2}/users/me").json()["discord_id"] is None
# --- login resolves an account -----------------------------------------------
def test_login_creates_an_account_and_signs_in(monkeypatch):
_mock_discord(monkeypatch, {"id": "d-new", "email": "New.User@Example.com",
"verified": True})
c = TestClient(appmod.app)
r = _callback(c, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=created" in r.headers["location"]
me = c.get(f"{V2}/users/me")
assert me.status_code == 200
body = me.json()
assert body["email"] == "new.user@example.com" # normalised
assert body["discord_id"] == "d-new"
assert body["is_verified"] is True # Discord vouched for it
assert body["discord_only"] is True
assert body["discord_dm"] is True
def test_login_recognises_a_previously_linked_account(monkeypatch):
"""The discord_id is the durable key: it resolves the account with no email."""
_mock_discord(monkeypatch, {"id": "d-returning", "email": "link-me@example.com",
"verified": True})
linker = TestClient(appmod.app)
_login(linker, "link-me@example.com")
uid = linker.get(f"{V2}/users/me").json()["id"]
assert "discord=linked" in _callback(linker, dl._sign_state(uid, "link")
).headers["location"]
# A fresh client, no session, and Discord now returns no email at all.
_mock_discord(monkeypatch, {"id": "d-returning"})
c = TestClient(appmod.app)
r = _callback(c, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=signedin" in r.headers["location"]
assert c.get(f"{V2}/users/me").json()["id"] == uid
def test_login_connects_a_verified_email_to_an_existing_account(monkeypatch):
"""This is the 'connect the account with that' path: an existing password
account picks up the Discord id and is signed in."""
c = TestClient(appmod.app)
_register(c, "existing@example.com")
_mock_discord(monkeypatch, {"id": "d-existing", "email": "existing@example.com",
"verified": True})
fresh = TestClient(appmod.app)
r = _callback(fresh, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=signedin" in r.headers["location"]
body = fresh.get(f"{V2}/users/me").json()
assert body["email"] == "existing@example.com"
assert body["discord_id"] == "d-existing"
# It kept its password, so it is not Discord-only and may unlink.
assert body["discord_only"] is False
def test_login_refuses_an_unverified_discord_email(monkeypatch):
"""Discord's verified flag is the only evidence the person owns the address.
Without it, this would hand over any account whose email someone typed in."""
c = TestClient(appmod.app)
_register(c, "victim@example.com")
_mock_discord(monkeypatch, {"id": "d-attacker", "email": "victim@example.com",
"verified": False})
fresh = TestClient(appmod.app)
r = _callback(fresh, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=unverified" in r.headers["location"]
assert fresh.get(f"{V2}/users/me").status_code == 401 # no session issued
# And the targeted account is untouched.
_login(c, "victim@example.com")
assert c.get(f"{V2}/users/me").json()["discord_id"] is None
def test_login_without_an_email_cannot_create_an_account(monkeypatch):
_mock_discord(monkeypatch, {"id": "d-noemail"})
c = TestClient(appmod.app)
r = _callback(c, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=noemail" in r.headers["location"]
assert c.get(f"{V2}/users/me").status_code == 401
def test_login_will_not_move_an_account_between_discord_identities(monkeypatch):
"""The email matches an account that is already linked to a different Discord
id signing in would silently re-point it."""
owner = TestClient(appmod.app)
_login(owner, "owned@example.com")
uid = owner.get(f"{V2}/users/me").json()["id"]
_mock_discord(monkeypatch, {"id": "d-owner", "email": "owned@example.com",
"verified": True})
assert "discord=linked" in _callback(owner, dl._sign_state(uid, "link")
).headers["location"]
# A second Discord account claiming the same address.
_mock_discord(monkeypatch, {"id": "d-interloper", "email": "owned@example.com",
"verified": True})
fresh = TestClient(appmod.app)
r = _callback(fresh, dl._sign_state(None, "login"))
assert r.status_code == 303 and "discord=mismatch" in r.headers["location"]
assert fresh.get(f"{V2}/users/me").status_code == 401
assert owner.get(f"{V2}/users/me").json()["discord_id"] == "d-owner"
# --- guards on the connected account -----------------------------------------
def test_linking_a_discord_account_someone_else_owns_is_refused(monkeypatch):
first = TestClient(appmod.app)
_login(first, "first-owner@example.com")
_mock_discord(monkeypatch, {"id": "d-contested", "email": "first-owner@example.com",
"verified": True})
uid = first.get(f"{V2}/users/me").json()["id"]
assert "discord=linked" in _callback(first, dl._sign_state(uid, "link")
).headers["location"]
# A different Thermograph account tries to claim the same Discord identity.
second = TestClient(appmod.app)
_login(second, "second-owner@example.com")
uid2 = second.get(f"{V2}/users/me").json()["id"]
r = _callback(second, dl._sign_state(uid2, "link"))
assert r.status_code == 303 and "discord=taken" in r.headers["location"]
assert second.get(f"{V2}/users/me").json()["discord_id"] is None
def test_a_discord_only_account_cannot_unlink_itself_into_a_lockout(monkeypatch):
_mock_discord(monkeypatch, {"id": "d-onlyway", "email": "onlyway@example.com",
"verified": True})
c = TestClient(appmod.app)
assert "discord=created" in _callback(c, dl._sign_state(None, "login")
).headers["location"]
r = c.post(f"{V2}/discord/unlink")
assert r.status_code == 409
assert "no password" in r.json()["detail"]
assert c.get(f"{V2}/users/me").json()["discord_id"] == "d-onlyway"
# Muting DMs is still fine — that is not a lockout.
assert c.post(f"{V2}/discord/dm", json={"enabled": False}).status_code == 204

View file

@ -25,7 +25,7 @@ from data import climate
from notifications import digest
from notifications import discord_interactions as discord_interactions_mod
from notifications import discord_link
from accounts import db
from accounts import db, oauth
from data import grid
from core import metrics
from notifications import notify
@ -988,7 +988,12 @@ app.include_router(
)
# Subscriptions + notifications (authed, user-scoped).
app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2")
# Discord account linking (OAuth2, authed).
# Sign in with / connect an external provider (Discord, Google).
app.include_router(oauth.router, prefix=f"{BASE}/api/v2")
# Discord's legacy /discord/* paths: the callback URL registered in Discord's
# portal, plus what the currently-deployed frontend calls. Mounted AFTER the
# generic router so neither shadows the other — the paths are disjoint, and
# /oauth/{provider}/... would otherwise be a candidate match for /discord/....
app.include_router(discord_link.router, prefix=f"{BASE}/api/v2")

View file

@ -10,8 +10,8 @@
// units.js is (imported by each page's entry module).
let currentUser = null; // {id, email, display_name} or null
let discordEnabled = false; // is Discord configured on the server?
let discordChecked = false; // have we asked yet? (once per page load)
let providers = []; // [{name, label, enabled}] from /oauth/config
let providersChecked = false; // have we asked yet? (once per page load)
const authCbs = []; // notified on login/logout so pages can re-gate
// backend's own origin+base (e.g. "https://thermograph.org/thermograph", or
@ -92,16 +92,24 @@ async function refreshUser() {
// Learn once whether Discord is configured, so we only offer "Link Discord" and
// "Continue with Discord" when they'll actually work. Asked regardless of sign-in
// state: the sign-in modal needs the answer precisely when nobody is signed in.
if (!discordChecked) {
discordChecked = true;
if (!providersChecked) {
providersChecked = true;
try {
const r = await apiFetch(uv("discord/config"));
if (r.ok) discordEnabled = (await r.json()).enabled === true;
} catch (e) { /* leave it hidden */ }
const r = await apiFetch(uv("oauth/config"));
if (r.ok) providers = ((await r.json()).providers || []).filter((p) => p.enabled);
} catch (e) { /* leave them hidden */ }
}
return currentUser;
}
// Providers the server can complete a flow with. Empty until refreshUser() has
// run, which is why the sign-in modal re-renders its provider row on every open
// rather than only when it is first built.
function enabledProviders() { return providers; }
function isLinked(name) {
return !!(currentUser && (currentUser.oauth_providers || []).includes(name));
}
// --- auth calls --------------------------------------------------------------
async function login(email, password) {
// fastapi-users login is an OAuth2 form: username=email, password.
@ -130,6 +138,23 @@ let modal = null, mode = "login";
// extra request and inherits currentColor.
const DISCORD_IC = `<svg viewBox="0 0 24 18" fill="currentColor" aria-hidden="true"><path d="M20.32 1.51A19.79 19.79 0 0 0 15.43 0a13.9 13.9 0 0 0-.63 1.28 18.4 18.4 0 0 0-5.6 0A13.9 13.9 0 0 0 8.57 0 19.74 19.74 0 0 0 3.68 1.51C.57 6.15-.28 10.68.14 15.14a19.9 19.9 0 0 0 6.05 3.05c.49-.66.92-1.37 1.3-2.11a12.9 12.9 0 0 1-2.05-.98c.17-.13.34-.26.5-.4a14.2 14.2 0 0 0 12.12 0c.16.14.33.27.5.4-.65.38-1.34.71-2.05.98.37.74.81 1.45 1.3 2.11a19.87 19.87 0 0 0 6.05-3.05c.49-5.17-.84-9.67-3.54-13.63ZM8.02 12.4c-1.18 0-2.15-1.08-2.15-2.41S6.82 7.58 8.02 7.58s2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41s.95-2.41 2.15-2.41 2.17 1.09 2.15 2.41c0 1.33-.95 2.41-2.15 2.41Z"/></svg>`;
// Google's mark is four fixed brand colours, so unlike the others it does not take
// currentColor — hence the white button beneath it, which is also what Google's
// branding terms require.
const GOOGLE_IC = `<svg viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.83.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"/><path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3-2.33Z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"/></svg>`;
const PROVIDER_IC = { discord: DISCORD_IC, google: GOOGLE_IC };
// The provider buttons under the sign-in form. Rebuilt on every open because
// `providers` is populated asynchronously and may still have been empty when the
// modal was first constructed.
function providerButtonsHtml() {
return enabledProviders().map((p) => `
<a class="acct-oauth-login is-${p.name}" href="${uv(`oauth/${p.name}/login/start`)}">
${PROVIDER_IC[p.name] || ""}<span>Continue with ${escapeHtml(p.label)}</span>
</a>`).join("");
}
function buildModal() {
modal = document.createElement("div");
modal.className = "mp-overlay acct-overlay";
@ -151,9 +176,7 @@ function buildModal() {
<button type="submit" class="acct-submit">Sign in</button>
<div class="acct-alt" hidden>
<span class="acct-or">or</span>
<a class="acct-discord-login" href="${uv("discord/login/start")}">
${DISCORD_IC}<span>Continue with Discord</span>
</a>
<div class="acct-oauth-row"></div>
</div>
<p class="acct-switch"></p>
</form>
@ -183,9 +206,11 @@ function setMode(m) {
modal.querySelector(".acct-switch").innerHTML = isLogin
? 'Need an account? <button type="button">Create one</button>'
: 'Already have an account? <button type="button">Sign in</button>';
// One label for both modes: Discord signs you in or creates the account,
// One label for both modes: the provider signs you in or creates the account,
// whichever applies, so making the user pick first would be a false choice.
modal.querySelector(".acct-alt").hidden = !discordEnabled;
const enabled = enabledProviders();
modal.querySelector(".acct-oauth-row").innerHTML = providerButtonsHtml();
modal.querySelector(".acct-alt").hidden = enabled.length === 0;
showError("");
}
@ -341,6 +366,30 @@ const USER_IC = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stro
let acctEl = null;
// The connected-accounts section of the account popover: one entry per provider
// the server offers, plus Discord's DM toggle, which is a delivery setting rather
// than an auth one and so only appears once Discord is actually linked.
function connectedAccountsHtml() {
const linkedCount = (currentUser.oauth_providers || []).length;
return enabledProviders().map((p) => {
if (!isLinked(p.name)) {
return `<a href="${uv(`oauth/${p.name}/link/start`)}" class="acct-pop-link">`
+ `Link ${escapeHtml(p.label)}</a>`;
}
const dm = p.name === "discord"
? `<button type="button" class="acct-pop-link acct-discord-dm">`
+ `${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
: "";
// With no password and nothing else linked, this is the only way back in and
// the server refuses to unlink it — so don't offer a button that can't work.
const stuck = currentUser.oauth_only && linkedCount <= 1;
return dm + (stuck
? `<span class="acct-pop-note muted">Signed in with ${escapeHtml(p.label)}</span>`
: `<button type="button" class="acct-pop-link acct-oauth-unlink" `
+ `data-provider="${p.name}">Unlink ${escapeHtml(p.label)}</button>`);
}).join("");
}
function ensureAcctEl() {
const brand = document.querySelector(".brand");
if (!brand) return null;
@ -382,14 +431,7 @@ function renderHeader() {
</button>
<div class="acct-pop" hidden>
<a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
${currentUser.discord_id
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
// A Discord-created account has no password, so Discord is its only
// way in and the server refuses to unlink it — don't offer the button.
+ (currentUser.discord_only
? '<span class="acct-pop-note muted">Signed in with Discord</span>'
: '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>')
: (discordEnabled ? `<a href="${uv("discord/link/start")}" class="acct-pop-link">Link Discord</a>` : "")}
${connectedAccountsHtml()}
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
</div>
</div>`;
@ -417,16 +459,18 @@ function renderHeader() {
}
});
el.querySelector(".acct-signout").addEventListener("click", logout);
const unlinkBtn = el.querySelector(".acct-discord-unlink");
if (unlinkBtn) unlinkBtn.addEventListener("click", async () => {
el.querySelectorAll(".acct-oauth-unlink").forEach((unlinkBtn) => {
unlinkBtn.addEventListener("click", async () => {
const provider = unlinkBtn.dataset.provider;
unlinkBtn.disabled = true;
try {
const res = await apiFetch(uv("discord/unlink"), { method: "POST" });
// 409: the account was created with Discord and has no password, so
const res = await apiFetch(uv(`oauth/${provider}/unlink`), { method: "POST" });
// 409: the account has no password and this was its last provider, so
// unlinking would lock it out. Say why instead of appearing to do nothing.
if (res.status === 409) {
const body = await res.json().catch(() => null);
showToast((body && body.detail) || "Discord can't be unlinked from this account.", true);
showToast((body && body.detail)
|| "That account can't be unlinked — it's the only way to sign in.", true);
unlinkBtn.disabled = false;
return;
}
@ -434,6 +478,7 @@ function renderHeader() {
await refreshUser();
emitAuth(); // repaint the popover in its unlinked state
});
});
const dmBtn = el.querySelector(".acct-discord-dm");
if (dmBtn) dmBtn.addEventListener("click", async () => {
dmBtn.disabled = true;
@ -479,28 +524,41 @@ function showToast(msg, isError = false) {
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
}
// --- Discord OAuth outcomes --------------------------------------------------
// --- OAuth outcomes ----------------------------------------------------------
// The link/login callback can only talk back to us through a redirect, so it
// lands on ?discord=<status>. Each status maps to [message, isError]; anything
// unrecognised is treated as a generic failure rather than shown raw.
const DISCORD_STATUS = {
linked: ["Discord connected.", false],
signedin: ["Signed in with Discord.", false],
created: ["Account created with Discord — welcome to Thermograph.", false],
cancelled: ["Discord sign-in cancelled.", false],
taken: ["That Discord account is already connected to another Thermograph account.", true],
mismatch: ["That email belongs to an account connected to a different Discord account. "
+ "Sign in with your password to change it.", true],
noemail: ["Discord didn't share an email address, so there's no account to sign in to.", true],
unverified: ["Verify your email address with Discord first, then try again.", true],
inactive: ["That account is deactivated.", true],
unavailable: ["Discord sign-in isn't available on this server.", true],
// lands on ?oauth=<status>&provider=<name>. Each status maps to a message builder
// taking the provider's display name; anything unrecognised is treated as a
// generic failure rather than shown raw.
const OAUTH_STATUS = {
linked: [(p) => `${p} connected.`, false],
signedin: [(p) => `Signed in with ${p}.`, false],
created: [(p) => `Account created with ${p} — welcome to Thermograph.`, false],
cancelled: [(p) => `${p} sign-in cancelled.`, false],
already: [(p) => `A different ${p} account is already connected here.`, true],
taken: [(p) => `That ${p} account is already connected to another Thermograph account.`, true],
mismatch: [(p) => `That email belongs to an account connected to a different ${p} `
+ "account. Sign in with your password to change it.", true],
noemail: [(p) => `${p} didn't share an email address, so there's no account to sign in to.`, true],
unverified: [(p) => `Verify your email address with ${p} first, then try again.`, true],
inactive: [() => "That account is deactivated.", true],
unavailable: [(p) => `${p} sign-in isn't available on this server.`, true],
};
function showDiscordStatus(status) {
const [msg, isError] = DISCORD_STATUS[status]
|| ["Something went wrong with Discord. Please try again.", true];
showToast(msg, isError);
function providerLabel(name) {
const known = providers.find((p) => p.name === name);
if (known) return known.label;
// The config probe may have failed, or the provider may have been switched off
// between starting the flow and coming back. Title-case the raw name rather
// than showing "undefined" in the toast.
return name ? name.charAt(0).toUpperCase() + name.slice(1) : "That provider";
}
function showOAuthStatus(status, provider) {
const label = providerLabel(provider);
const entry = OAUTH_STATUS[status];
if (!entry) return showToast(`Something went wrong with ${label}. Please try again.`, true);
const [build, isError] = entry;
showToast(build(label), isError);
}
// --- boot --------------------------------------------------------------------
@ -513,18 +571,20 @@ function showDiscordStatus(status) {
const params = new URLSearchParams(location.search);
const token = params.get("verify_token");
// Both of these are one-shot handoffs from a redirect; strip them together so a
// reload can't replay either, and so the two never fight over the URL.
const discordStatus = params.get("discord");
if (token || discordStatus) {
params.delete("verify_token");
params.delete("discord");
// `discord` is the pre-Google spelling: a backend older than this frontend sends
// only that one, so fall back to it and assume the provider it implies.
const oauthStatus = params.get("oauth") || params.get("discord");
const oauthProvider = params.get("provider") || (params.get("discord") ? "discord" : "");
// These are one-shot handoffs from a redirect; strip them together so a reload
// can't replay any of them, and so they never fight over the URL.
if (token || oauthStatus) {
["verify_token", "oauth", "provider", "discord"].forEach((k) => params.delete(k));
const clean = location.pathname + (params.toString() ? `?${params}` : "") + location.hash;
history.replaceState(null, "", clean);
}
// refreshUser() above already ran, and the callback's redirect carried the new
// session cookie, so the header is painted signed-in before this toast lands.
if (discordStatus) showDiscordStatus(discordStatus);
if (oauthStatus) showOAuthStatus(oauthStatus, oauthProvider);
if (token) {
try {
await verifyEmail(token);

View file

@ -436,14 +436,26 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.acct-or::before, .acct-or::after {
content: ""; flex: 1; height: 1px; background: var(--border);
}
.acct-discord-login {
.acct-oauth-row { display: flex; flex-direction: column; gap: 8px; }
.acct-oauth-login {
display: flex; align-items: center; justify-content: center; gap: 9px;
padding: 12px 16px; min-height: 44px; border-radius: 10px;
background: var(--discord); color: #fff; font-weight: 700; font-size: 15px;
text-decoration: none; border: 1px solid transparent;
font-weight: 700; font-size: 15px; text-decoration: none;
border: 1px solid transparent;
}
.acct-discord-login:hover { filter: brightness(1.08); }
.acct-discord-login svg { width: 20px; height: 15px; flex: none; }
.acct-oauth-login:hover { filter: brightness(1.08); }
.acct-oauth-login svg { width: 20px; height: 20px; flex: none; }
.acct-oauth-login.is-discord {
background: var(--discord); color: #fff;
}
.acct-oauth-login.is-discord svg { height: 15px; }
/* Google's branding terms require their mark on white (or their own grey), with a
visible border so this one button is deliberately light in both schemes and
does not follow the surface tokens. */
.acct-oauth-login.is-google {
background: #fff; color: #1f1f1f; border-color: #747775;
}
.acct-oauth-login.is-google:hover { filter: none; background: #f2f2f2; }
.acct-pop-note { display: block; padding: 9px 12px; font-size: 13px; }
.acct-switch { margin: 0; font-size: 13px; color: var(--muted); text-align: center; }

View file

@ -224,6 +224,18 @@ THERMOGRAPH_BASE_URL=https://thermograph.org
# for per request, not registered, so the email scope needs no portal change
# either. An environment without these two vars simply shows no Discord UI.
#THERMOGRAPH_DISCORD_CLIENT_SECRET=
# Sign in with Google / connect a Google account — the same engine as Discord
# above (accounts/oauth.py), configured independently, so an environment may offer
# either, both, or neither. From Google Cloud Console -> APIs & Services ->
# Credentials -> OAuth 2.0 Client ID, type "Web application". The CLIENT_SECRET is
# a credential. Register this exact redirect URI on the client:
# https://thermograph.org/api/v2/oauth/google/callback
# (Google matches redirect URIs exactly — scheme, host and path — so beta and dev
# each need their own entry on the same client, or their own client.) The consent
# screen needs only the `openid` and `email` scopes, both non-sensitive, so it
# requires no Google verification review.
#THERMOGRAPH_GOOGLE_CLIENT_ID=
#THERMOGRAPH_GOOGLE_CLIENT_SECRET=
# Gateway bot (opt-in): holds a live websocket so the bot replies to messages that
# @mention it (or DM it) with a city grade — e.g. "@Thermograph Phoenix". Reuses
# THERMOGRAPH_DISCORD_BOT_TOKEN above. Runs on the single notifier leader only, so