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:
co-authored by
Claude Opus 5.5
parent
e54191a5d0
commit
db4e326332
@@ -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"]
|
||||
Reference in New Issue
Block a user