thermograph/backend/notifications/discord_link.py

386 lines
16 KiB
Python
Raw Normal View History

accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
"""Discord as an identity: sign in with it, and connect it to an account.
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
Two flows over one authorization-code grant, told apart by the signed `state`:
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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
**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
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import httpx
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from accounts.db import get_async_session
from accounts.models import User
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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"
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
# 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))
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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}"
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
# 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"
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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:
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
# 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():
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
"""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()}
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
if not enabled():
return _account_redirect("unavailable")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
return _authorize_redirect(request, _SCOPE_LINK, _sign_state(str(user.id), "link"))
@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"))
@router.get("/discord/link/callback")
async def link_callback(
request: Request,
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
user=Depends(current_user_optional),
session: AsyncSession = Depends(get_async_session),
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
user_manager=Depends(get_user_manager),
access_token_db=Depends(get_access_token_db),
):
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
"""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")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
# 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")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
profile = await _fetch_profile(request, code)
if profile is None:
return _account_redirect("error")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
discord_id = str(profile.get("id") or "")
if not discord_id:
return _account_redirect("error")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
# 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")
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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)
@router.post("/discord/unlink", status_code=204)
async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
accounts: sign in with Discord, sharing the existing link callback Discord was already linkable from a signed-in session; it could not authenticate one. Adds /discord/login/start, which resolves a Discord identity to an account and issues a session. Both flows return to the existing /discord/link/callback. Discord only honours redirect URIs registered in the developer portal, so a second callback path would have blocked this behind an operator change; the signed state now carries a purpose, and since that is inside the HMAC a state can only verify under the flow it was minted for. Account resolution, in order: an existing discord_id (the durable key, no email needed); otherwise the Discord email, but only when Discord reports it verified — that flag is the sole evidence the person owns the address, and matching on an unverified one would hand over the account. Failing both, a new account is created. A login state carries no user id, so it is replayable against whoever is signed in. It therefore never links: doing so would be a forced-linking takeover. Only link/start, whose state is bound to a user id, may link. Accounts created this way have no password their owner has ever seen, so user.discord_only records that and unlink is refused for them — no reset-password router is mounted, so unlinking would be unrecoverable. Migration 0003 adds the column conditionally: 0001 builds the schema from live model metadata, so a fresh database already has it. Also fixes two latent bugs in the link flow: the callback redirected to /subscriptions, which no route serves (it is /alerts), and nothing ever read the ?discord= status it has always sent, so a completed link gave no feedback. Both now surface as a toast. Linking a Discord account that another account already owns returned a 500 from the unique constraint; it now explains itself.
2026-07-26 17:43:17 +00:00
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()
class DmToggle(BaseModel):
enabled: bool
@router.post("/discord/dm", status_code=204)
async def set_dm(
payload: DmToggle,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Turn Discord DM alerts on or off without unlinking. A no-op unless a Discord
account is actually linked."""
await session.execute(
update(User).where(User.id == user.id, User.discord_id.is_not(None))
.values(discord_dm=payload.enabled)
)
await session.commit()