Files
server-26/drb-c2-core/app/routers/tokens.py
T
Logan CusanoandClaude Opus 5.5 db4e326332 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>
2026-10-05 02:52:57 -04:00

343 lines
16 KiB
Python

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 (
describe_actor,
get_org_role,
get_role,
require_admin_token,
require_firebase_token,
resolve_org_scope,
)
from app.internal.logger import logger
router = APIRouter(prefix="/tokens", tags=["tokens"])
class TokenCreate(BaseModel):
name: str # friendly label e.g. "DRB Bot 1"
token: str # the actual Discord bot token
# None = platform pool. An org id = that org's bring-your-own bot, never
# leased to anyone else (discord_leases.pick_token).
org_id: Optional[str] = None
# 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
async def _republish_after_release(lease: Optional[dict]) -> None:
if lease and lease.get("node_id"):
from app.internal.discord_streams import publish_desired
await publish_desired(lease["node_id"])
# ---------------------------------------------------------------------------
# CRUD — platform admin only (server-26#204 item 6: the list used to be open
# to any signed-in user of any org)
# ---------------------------------------------------------------------------
@router.get("")
async def list_tokens(_: dict = Depends(require_admin_token)):
"""List all tokens. The actual token string is masked for safety."""
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)
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
"""Add a radio bot. The token is checked against Discord first
(server-26#204 item 5): a string Discord answers 401 for is refused; if
Discord is merely unreachable the token is stored with membership unknown."""
try:
info = await discord_rest.inspect_token(body.token.strip())
except discord_rest.TokenRejected as e:
raise HTTPException(400, str(e))
token_id = str(uuid.uuid4())
doc = {
"token_id": token_id,
"name": body.name,
"token": body.token.strip(),
"org_id": body.org_id,
"lease": None,
"in_use": False,
"assigned_node_id": None,
"assigned_at": None,
**info,
}
await fstore.doc_set("bot_tokens", token_id, doc, merge=False)
return {"token_id": token_id, "name": body.name, "valid": info.get("valid"),
"guild_ids": info.get("guild_ids"), "check_error": info.get("check_error"),
"invite_url": leases.invite_url(info.get("application_id"))}
@router.post("/{token_id}/refresh", status_code=200)
async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
"""Re-check a bot with Discord (valid? which servers is it in?)."""
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 {}, await discord_oauth.enabled_token_ids())
@router.post("/flush", status_code=200)
async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release every leased token (admin last resort). Each holding node
is sent a desired-state list without the token, so it logs the bot out;
persistent streams that lost their token are re-placed by the reconciler."""
released = 0
for t in await fstore.collection_list("bot_tokens"):
if not (t.get("lease") or t.get("in_use")):
continue
lease = await leases.force_release_token(t["token_id"])
released += 1
await _republish_after_release(lease)
logger.warning(f"Admin flushed {released} radio-bot token lease(s)")
return {"released": released}
def _forget_cached_system(system_id: str) -> None:
"""start_stream reads systems through fstore.doc_get_cached (5 min TTL):
drop the entry so a new preference applies to the next start instead of
up to five minutes later (server-26#225 S4)."""
cache = getattr(fstore, "_doc_cache", None)
path = getattr(fstore, "_path", None)
if isinstance(cache, dict) and callable(path):
cache.pop(f"{path('systems')}/{system_id}", None)
def _may_edit_system(decoded: dict, system: Optional[dict]) -> bool:
"""Platform admin: any system. Org owner: only their own org's systems —
a shared system (no org_id) serves every org, so a preference there is
platform-admin only (owner decision Q2, 2026-10-05)."""
if get_role(decoded) == "admin":
return True
return bool(system and system.get("org_id") and system.get("org_id") == decoded.get("org_id"))
def _check_prefer_access(decoded: dict, token: Optional[dict], system: Optional[dict],
system_id: Optional[str]) -> None:
"""Raise unless the caller may point ``system`` at ``token``. Another
org's bot or system is a 404, never a 403 (no existence leak)."""
if get_role(decoded) == "admin":
if not token:
raise HTTPException(404, "Token not found.")
if system_id is not None and not system:
raise HTTPException(404, "System not found.")
return
org_id = decoded.get("org_id")
if not org_id or get_org_role(decoded) != "owner":
raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
if not token or (token.get("org_id") is not None and token.get("org_id") != org_id):
raise HTTPException(404, "Token not found.")
if token.get("org_id") is None:
raise HTTPException(403, "Only a platform admin can make a shared DRB radio bot preferred. "
"Organization owners can choose their own organization's radio bots.")
if system_id is None:
return
if not system or (system.get("org_id") and system.get("org_id") != org_id):
raise HTTPException(404, "System not found.")
if not system.get("org_id"):
raise HTTPException(403, "That system is shared by every organization; only a platform admin can "
"choose its radio bot.")
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(
token_id: str,
system_id: str,
decoded: dict = Depends(require_firebase_token),
):
"""
Mark this token as the preferred bot for a system: ``start_stream`` tries
it first for that system's radio channels when nobody named a bot
(server-26#225 S3), and uses another bot when it can't take the stream.
Pass system_id="_none" to clear the preference.
Who (owner decision Q2, 2026-10-05): a platform admin for any bot and any
system; an org owner for their OWN org's systems and their OWN
bring-your-own bots. Shared systems (no org_id) and pool bots stay
platform-admin only.
"""
if "/" in token_id or "/" in system_id:
raise HTTPException(400, "Bad id.")
existing = await fstore.doc_get("bot_tokens", token_id)
uid, email = describe_actor(decoded)
if system_id == "_none":
_check_prefer_access(decoded, existing, None, None)
old_id = existing.get("preferred_for_system_id") or ""
system_doc = await fstore.doc_get("systems", old_id) if old_id else None
if system_doc and system_doc.get("preferred_token_id") == token_id:
if not _may_edit_system(decoded, system_doc):
raise HTTPException(403, "That system is shared by every organization; only a platform admin "
"can change its radio bot.")
await fstore.doc_set("systems", old_id, {"preferred_token_id": None})
_forget_cached_system(old_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": None, "previous_system_id": old_id or None})
return {"ok": True, "preferred_for_system_id": None}
system_doc = await fstore.doc_get("systems", system_id)
_check_prefer_access(decoded, existing, system_doc, system_id)
# Keep both pointers single-valued: this bot stops being preferred for the
# system it was preferred for (when the caller may edit that system), and
# the bot this system preferred before loses its back-pointer.
old_sys_id = existing.get("preferred_for_system_id")
if old_sys_id and old_sys_id != system_id:
old_sys = await fstore.doc_get("systems", old_sys_id)
if old_sys and old_sys.get("preferred_token_id") == token_id and _may_edit_system(decoded, old_sys):
await fstore.doc_set("systems", old_sys_id, {"preferred_token_id": None})
_forget_cached_system(old_sys_id)
old_tok_id = system_doc.get("preferred_token_id")
if old_tok_id and old_tok_id != token_id:
old_tok = await fstore.doc_get("bot_tokens", old_tok_id)
if old_tok and old_tok.get("preferred_for_system_id") == system_id:
await fstore.doc_set("bot_tokens", old_tok_id, {"preferred_for_system_id": None})
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
_forget_cached_system(system_id)
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
await write_audit(uid, email, "discord.token.prefer", details={
"token_id": token_id, "system_id": system_id, "previous_token_id": old_tok_id,
"org_id": system_doc.get("org_id")})
return {"ok": True, "preferred_for_system_id": system_id}
@router.get("/org/preferred")
async def org_preferred_bots(
org_id: Optional[str] = Query(None, description="Platform-admin only: act for this org."),
decoded: dict = Depends(require_firebase_token),
):
"""What an org owner needs to choose a preferred radio bot per system
(server-26#225 S4, owner decision Q2): the org's own systems, each with
its preferred bot, and the org's own bring-your-own bots — the only bots
an owner may choose (PUT /tokens/{id}/prefer/{system_id}). GET /tokens
stays platform-admin only; this returns no pool bots and no token strings.
A pool bot a platform admin chose for one of these systems is flagged,
not named."""
if get_role(decoded) != "admin" and get_org_role(decoded) != "owner":
raise HTTPException(403, "Only the organization owner or a platform admin can choose a system's radio bot.")
org = resolve_org_scope(decoded, org_id)
systems = sorted(await fstore.collection_list("systems", org_id=org),
key=lambda s: str(s.get("name") or s.get("system_id") or "").lower())
bots = sorted((t for t in await fstore.collection_list("bot_tokens", org_id=org) if t.get("token_id")),
key=lambda t: str(t.get("name") or "").lower())
names = {t["token_id"]: t.get("name") or t["token_id"] for t in bots}
out_systems = []
for s in systems:
if not s.get("system_id"):
continue
pref = s.get("preferred_token_id")
out_systems.append({
"system_id": s["system_id"],
"name": s.get("name") or s["system_id"],
"preferred_token_id": pref if pref in names else None,
"preferred_is_platform_bot": bool(pref) and pref not in names,
"preferred_name": names.get(pref) if pref in names else None,
})
return {
"org_id": org,
"systems": out_systems,
"bots": [{
"token_id": t["token_id"],
"name": names[t["token_id"]],
"valid": t.get("valid"),
"server_count": len(t["guild_ids"]) if isinstance(t.get("guild_ids"), list) else None,
"preferred_for_system_id": t.get("preferred_for_system_id"),
"invite_url": leases.invite_url(t.get("application_id")),
} for t in bots],
}
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)
if not existing:
raise HTTPException(404, "Token not found.")
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}