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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user