Discord: per-bot OAuth "Connect a Discord server" backend (server-26#225 S6)

An org owner links a server by inviting one radio bot through that bot's
own OAuth2 invite (owner decision Q1). The state is a single-use, 10-minute
nonce bound to org + user (stored only as its sha256); the server linked is
the one Discord's code exchange names, never the redirect query.

- routers/discord_oauth.py: GET /discord/oauth/options, POST /start,
  POST /complete. 404 while discord_oauth_redirect_uri is empty or no
  usable bot has a secret. Bot order: explicit token_id, the node system's
  preferred bot, the org's own bot, least-full pool bot under the cap.
- internal/discord_oauth.py: state mint/consume transaction (login-CSRF:
  state.uid must be the caller; any presentation burns it), code exchange,
  revoke, client-credentials secret check. Through discord_rest's shared
  client and breaker; secrets/codes/tokens never logged or in errors.
- discord_guild_link: one link transaction body (_apply_link) and one
  settle step that stops a server's streams when the org it acted for
  changes (plan finding 3). /drb link redeem, the admin PUT link and OAuth
  all use it; the previous org is now read inside the link transaction.
- tokens: GET /tokens is an allowlist (+ oauth_enabled, server_count,
  server_cap); PUT/DELETE /tokens/{id}/oauth (admin) checks the secret with
  Discord before storing it in bot_oauth/{id}, never echoes it.
- firestore.rules: explicit deny for bot_oauth and discord_oauth_states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:52:57 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit db4e326332
10 changed files with 1177 additions and 60 deletions
+109 -25
View File
@@ -27,6 +27,16 @@ Firestore layout:
unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id.
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
through ONE transaction body (``_apply_link``) and then runs ONE settle step
(``_settle``): a server that was acting for a different org (in practice the
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
stopped, because they were placed on that org's nodes. The org it acted for
before is read inside the same transaction as the write, so the stop decision
can't race a concurrent link.
"""
from __future__ import annotations
@@ -35,6 +45,7 @@ import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
CODES = "discord_link_codes"
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
"expires_minutes": CODE_TTL_MINUTES}
# Stop reasons the settle step stamps on the streams it stops.
RELINK_STOP_REASON = "relinked"
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
allow_move: bool = False, extra: Optional[dict] = None):
"""The guild-side half of every link, inside a transaction whose reads are
all done. Returns a LinkError (nothing written) or the result dict:
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
org the server acted for before (its link, else the fallback org).
``allow_move`` is the platform admin's support override; every self-serve
path refuses to move a server another org has linked."""
current = (guild or {}).get("org_id")
if current and current != org_id and not allow_move:
return LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")
iso = now.isoformat()
name = (guild_name or "").strip()[:100]
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": name or (guild or {}).get("guild_name"),
"linked_by": linked_by,
"linked_via": via,
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
**(extra or {}),
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"previous_org_id": streams.acting_org_of(guild)}
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
db = fstore.db
when = now or _now()
def _txn(txn):
guild_ref = db.collection(GUILDS).document(guild_id)
snap = guild_ref.get(transaction=txn)
guild = snap.to_dict() if snap.exists else None
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
return {"error": out} if isinstance(out, LinkError) else out
return _txn
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write."""
db = fstore.db
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")}
org_id = doc["org_id"]
current = (guild or {}).get("org_id")
if current and current != org_id:
return {"error": LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
if isinstance(out, LinkError):
return {"error": out} # the code is NOT consumed: its org can use it elsewhere
txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return {**out, "created_by_uid": doc.get("created_by_uid")}
return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.
Does NOT stop anything — routes call ``redeem_and_settle``."""
code = normalize(raw_code)
if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code",
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
return result
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
"""After a link committed: a server that was acting for another org (the
fallback org, or — admin override only — another linked org) has its live
radio channels stopped; they were placed on that org's nodes, and left
running the reconciler would keep re-placing them there
(server-26#225 plan finding 3: this lived in two routers before)."""
before = result.get("previous_org_id")
stopped: list = []
if before and before != result["org_id"]:
stopped = await streams.stop_guild(str(guild_id), stop_reason)
return {**result, "streams_stopped": stopped}
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None,
stop_reason: str = RELINK_STOP_REASON) -> dict:
"""Link a server to an org directly (admin, OAuth) and settle. Raises
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
result = await fstore.run_transaction(
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
if "error" in result:
raise result["error"]
return await _settle(guild_id, result, stop_reason)
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
discord_user_id: str) -> dict:
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
return await _settle(guild_id, result, RELINK_STOP_REASON)
async def list_org_guilds(org_id: str) -> list[dict]:
return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
+246
View File
@@ -0,0 +1,246 @@
"""
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
An org owner links a Discord server by inviting ONE radio bot through
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
short-lived nonce bound to the org and the user who started it; Discord
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
THAT bot application's client secret, and the exchange's ``guild`` object —
never the query string — says which server to link.
Secrets:
bot_oauth/{token_id} server-only (Firestore rules deny all
client_secret, enabled, client reads; c2-core's admin SDK only)
set_by, set_at, checked_at
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
those fields), never returned by any route, never logged, never put in an
error string. Firestore encrypts it at rest like every other document; the
codebase has no application-level encryption pattern (bot tokens are
stored the same way), so none is invented here.
States:
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
used_at|None, used_by|None
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
route 404s and the UI hides the button.
HTTP goes through discord_rest's shared client (User-Agent, transport for
tests) and counts 401/403/429 answers against its process-wide breaker, so a
wrong secret retried in a loop can't contribute to an IP ban either.
"""
from __future__ import annotations
import hashlib
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlencode
import httpx
from app.config import settings
from app.internal import discord_leases as leases
from app.internal import discord_rest
from app.internal import firestore as fstore
OAUTH = "bot_oauth"
STATES = "discord_oauth_states"
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
# Discord client secrets are 32 url-safe characters today; accept a margin.
SECRET_MIN, SECRET_MAX = 16, 128
RETURN_TO_MAX = 200
def enabled() -> bool:
return bool((settings.discord_oauth_redirect_uri or "").strip())
def _now() -> datetime:
return datetime.now(timezone.utc)
def state_key(nonce: str) -> str:
return hashlib.sha256((nonce or "").encode()).hexdigest()
def safe_return_to(value: Optional[str]) -> Optional[str]:
"""A same-site path only (``/nodes/abc``), never an absolute or
protocol-relative URL — the callback page redirects to it."""
v = (value or "").strip()
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
return None
if any(ord(ch) < 0x20 for ch in v):
return None
return v
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
async def enabled_token_ids() -> set[str]:
"""Bots whose OAuth secret is set and enabled."""
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
async def load_secret(token_id: str) -> Optional[str]:
doc = await fstore.doc_get(OAUTH, token_id)
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
return None
return doc["client_secret"]
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
now = _now().isoformat()
await fstore.doc_set(OAUTH, token_id, {
"token_id": token_id, "client_secret": client_secret, "enabled": True,
"set_by": uid, "set_at": now, "checked_at": now,
}, merge=False)
async def clear_secret(token_id: str) -> bool:
if not await fstore.doc_get(OAUTH, token_id):
return False
await fstore.doc_delete(OAUTH, token_id)
return True
# ---------------------------------------------------------------------------
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
# ---------------------------------------------------------------------------
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
"""POST an application-authenticated form. Never raises for a Discord or
network failure; the error string never holds the secret, the code or a
token (only Discord's short ``error`` field)."""
now = time.monotonic()
if discord_rest._breaker.tripped(now):
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
try:
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
except httpx.HTTPError as e:
return discord_rest.RestResult(None, error=type(e).__name__)
status = r.status_code
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
discord_rest._breaker.record(time.monotonic())
body: Any = None
try:
body = r.json() if r.content else None
except ValueError:
body = None
if 200 <= status < 300:
return discord_rest.RestResult(status, body)
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
return await _post_form("/oauth2/token", {
"grant_type": "authorization_code", "code": code,
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
}, client_id, client_secret)
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
"""Best effort: C2 never uses the user's access token, so it is revoked
as soon as the guild has been read from the exchange."""
if access_token:
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
client_id, client_secret)
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
credentials grant (scope ``identify`` — the bot owner's own token), then
revoke the token it returns. Nothing is stored from the answer."""
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
client_id, client_secret)
if r.ok and isinstance(r.data, dict):
await revoke(client_id, client_secret, r.data.get("access_token"))
return r
# ---------------------------------------------------------------------------
# State + authorize URL
# ---------------------------------------------------------------------------
def authorize_url(application_id: str, nonce: str) -> str:
return AUTHORIZE_URL + "?" + urlencode({
"client_id": application_id,
"scope": "bot",
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
"response_type": "code",
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
"state": nonce,
})
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
return_to: Optional[str]) -> tuple[str, datetime]:
nonce = secrets.token_urlsafe(32)
now = _now()
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
await fstore.doc_set(STATES, state_key(nonce), {
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
"used_at": None, "used_by": None,
}, merge=False)
return nonce, expires_at
class StateError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(message)
self.status, self.code, self.message = status, code, message
def consume_state_txn(nonce: str, uid: str, now: datetime):
"""Transaction body: the state must exist, be unexpired and unused, and
belong to ``uid`` (login-CSRF: someone else's state never links into the
caller's org, and the caller's own code can't be replayed by another
account). Any presentation of a live state burns it — a failure later in
the flow means starting again, never retrying the same state."""
db = fstore.db
key = state_key(nonce)
def _txn(txn):
ref = db.collection(STATES).document(key)
snap = ref.get(transaction=txn)
doc = snap.to_dict() if snap.exists else None
if not doc:
return {"error": StateError(400, "invalid_state",
"That Discord link is not valid. Start again from DRB.")}
if doc.get("used_at"):
return {"error": StateError(409, "state_used",
"That Discord link was already used. Start again from DRB.")}
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
try:
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
except ValueError:
expired = True
if expired:
return {"error": StateError(410, "state_expired",
"That Discord link expired. Start again from DRB.")}
if doc.get("uid") != uid:
return {"error": StateError(403, "wrong_user",
"That Discord link was started by a different DRB account. "
"Sign in as that account, or start again.")}
return {"state": doc}
return _txn
async def consume_state(nonce: str, uid: str) -> dict:
if not nonce or len(nonce) > 200:
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
if "error" in out:
raise out["error"]
return out["state"]
+12 -6
View File
@@ -93,6 +93,17 @@ def _parse_ts(value) -> Optional[datetime]:
# Guild -> org
# ---------------------------------------------------------------------------
def acting_org_of(link: Optional[dict]) -> Optional[str]:
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
no doc). Pure, so a link transaction can decide from the snapshot it read
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
@@ -100,12 +111,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]: