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
+79 -4
View File
@@ -2,8 +2,10 @@ import uuid
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from typing import Optional
from app.config import settings
from app.internal import firestore as fstore
from app.internal import discord_leases as leases
from app.internal import discord_oauth
from app.internal import discord_rest
from app.internal.audit import write_audit
from app.internal.auth import (
@@ -27,9 +29,27 @@ class TokenCreate(BaseModel):
org_id: Optional[str] = None
def _masked(t: dict) -> dict:
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]}
# What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an
# allowlist, so a field added to the doc later — a secret, say — never
# leaks by default). The token string itself is masked; OAuth client secrets
# live in bot_oauth/{id}, never on this doc, and are never returned.
_PUBLIC_TOKEN_FIELDS = (
"token_id", "name", "org_id", "valid", "guild_ids", "check_error", "validated_at",
"application_id", "bot_user_id", "bot_name", "lease", "in_use", "assigned_node_id", "assigned_at",
"preferred_for_system_id",
)
def _masked(t: dict, oauth_ids: Optional[set] = None) -> dict:
out = {k: t[k] for k in _PUBLIC_TOKEN_FIELDS if k in t}
out["token"] = "•••" + (t.get("token") or "")[-4:]
out["invite_url"] = leases.invite_url(t.get("application_id"))
members = t.get("guild_ids")
out["server_count"] = len(members) if isinstance(members, list) else None
# Pool bots stop being offered at the cap (owner decision Q3); BYO bots have none.
out["server_cap"] = settings.discord_pool_bot_guild_cap if t.get("org_id") is None else None
if oauth_ids is not None:
out["oauth_enabled"] = t.get("token_id") in oauth_ids
return out
@@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None:
@router.get("")
async def list_tokens(_: dict = Depends(require_admin_token)):
"""List all tokens. The actual token string is masked for safety."""
return [_masked(t) for t in await fstore.collection_list("bot_tokens")]
oauth_ids = await discord_oauth.enabled_token_ids()
return [_masked(t, oauth_ids) for t in await fstore.collection_list("bot_tokens")]
@router.post("", status_code=201)
@@ -83,7 +104,7 @@ async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
if not await fstore.doc_get("bot_tokens", token_id):
raise HTTPException(404, "Token not found.")
await discord_rest.refresh_membership([token_id])
return _masked(await fstore.doc_get("bot_tokens", token_id) or {})
return _masked(await fstore.doc_get("bot_tokens", token_id) or {}, await discord_oauth.enabled_token_ids())
@router.post("/flush", status_code=200)
@@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
if existing.get("lease"):
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
await fstore.doc_delete("bot_tokens", token_id)
await discord_oauth.clear_secret(token_id)
class OAuthSecret(BaseModel):
# Length is checked in the handler, not here: a pydantic 422 echoes the
# rejected input back in its body.
client_secret: str
@router.put("/{token_id}/oauth", status_code=200)
async def set_oauth_secret(token_id: str, body: OAuthSecret, decoded: dict = Depends(require_admin_token)):
"""Set (or replace) this radio bot application's OAuth2 client secret, so
"Connect a Discord server" can invite it (server-26#225 S6). Checked with
Discord first (client-credentials grant) — a wrong secret is refused and
nothing is stored. The secret goes to bot_oauth/{id}, server-side only:
it is never echoed, never returned by any route and never logged."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
tok = await fstore.doc_get("bot_tokens", token_id)
if not tok:
raise HTTPException(404, "Token not found.")
if not tok.get("application_id"):
raise HTTPException(409, "DRB doesn't know this bot's application id yet. Re-check the bot, then try again.")
secret = (body.client_secret or "").strip()
if not (discord_oauth.SECRET_MIN <= len(secret) <= discord_oauth.SECRET_MAX):
raise HTTPException(400, "That doesn't look like a Discord client secret. Copy it from the Discord "
"Developer Portal → OAuth2.")
r = await discord_oauth.check_secret(str(tok["application_id"]), secret)
uid, email = describe_actor(decoded)
if not r.ok:
await write_audit(uid, email, "discord.token.oauth_refused", details={
"token_id": token_id, "status": r.status})
if r.status in (400, 401):
raise HTTPException(400, "Discord didn't accept that client secret for this bot's application. "
"Copy it again from the Discord Developer Portal → OAuth2.")
raise HTTPException(503, "Couldn't check the secret with Discord right now. Nothing was saved; "
"try again in a minute.")
await discord_oauth.store_secret(token_id, secret, uid)
await write_audit(uid, email, "discord.token.oauth_set", details={"token_id": token_id})
logger.info(f"Radio bot {token_id}: OAuth secret set by uid={uid}")
return {"ok": True, "token_id": token_id, "oauth_enabled": True,
"redirect_uri_configured": discord_oauth.enabled()}
@router.delete("/{token_id}/oauth", status_code=200)
async def clear_oauth_secret(token_id: str, decoded: dict = Depends(require_admin_token)):
"""Forget this bot's OAuth secret: Connect stops offering it."""
if "/" in token_id:
raise HTTPException(400, "Bad id.")
removed = await discord_oauth.clear_secret(token_id)
uid, email = describe_actor(decoded)
if removed:
await write_audit(uid, email, "discord.token.oauth_clear", details={"token_id": token_id})
return {"ok": True, "token_id": token_id, "oauth_enabled": False}