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
@@ -17,7 +17,6 @@ Auth:
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Security
|
||||
@@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_guild_link as glink
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
@@ -181,23 +181,18 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
|
||||
org = await fstore.doc_get("organizations", body.org_id)
|
||||
if not org:
|
||||
raise HTTPException(404, "Organization not found.")
|
||||
previous_org = await streams.resolve_guild_org(guild_id)
|
||||
doc = {
|
||||
"guild_id": guild_id, "org_id": body.org_id,
|
||||
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
|
||||
# Re-linking a server that was unlinked or removed brings it back.
|
||||
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None,
|
||||
}
|
||||
if body.guild_name and body.guild_name.strip():
|
||||
doc["guild_name"] = body.guild_name.strip()[:100]
|
||||
await fstore.doc_set(streams.GUILDS, guild_id, doc)
|
||||
# Live channels in this server were placed on the previous org's nodes;
|
||||
# left running, the reconciler would keep re-placing them there, so one
|
||||
# org's equipment would keep serving a server that now belongs to another.
|
||||
stopped: list[str] = []
|
||||
if previous_org and previous_org != body.org_id:
|
||||
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
|
||||
# Support override: may move a server between orgs. Re-linking a server
|
||||
# that was unlinked or removed brings it back. Live channels placed on the
|
||||
# previous org's nodes are stopped (glink._settle) — left running, the
|
||||
# reconciler would keep re-placing them there.
|
||||
out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
|
||||
"admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
|
||||
uid, email = describe_actor(decoded)
|
||||
await write_audit(uid, email, "discord.guild.link", details={
|
||||
"guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
|
||||
"already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
|
||||
})
|
||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
|
||||
|
||||
|
||||
@router.delete("/streams/{stream_id}")
|
||||
|
||||
@@ -101,23 +101,21 @@ async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = De
|
||||
redeem_guild_limiter.check(f"guild:{guild_id}")
|
||||
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
||||
actor = f"discord:{body.discord_user_id}"
|
||||
before = await streams.resolve_guild_org(guild_id)
|
||||
try:
|
||||
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
|
||||
# Links, then stops anything the server had playing for the org it
|
||||
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
|
||||
result = await glink.redeem_and_settle(body.code, guild_id, body.guild_name, body.discord_user_id)
|
||||
except glink.LinkError as e:
|
||||
await write_audit(actor, "", "discord.guild.link_refused",
|
||||
details={"guild_id": guild_id, "reason": e.code})
|
||||
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
||||
|
||||
org_id = result["org_id"]
|
||||
stopped: list = []
|
||||
if before and before != org_id:
|
||||
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
|
||||
# had playing belongs to that org, not the one it just linked to.
|
||||
stopped = await streams.stop_guild(guild_id, "relinked")
|
||||
stopped = result["streams_stopped"]
|
||||
await write_audit(actor, "", "discord.guild.link", details={
|
||||
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
||||
"via": "code",
|
||||
})
|
||||
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
||||
org = await fstore.doc_get("organizations", org_id)
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
"Connect a Discord server" through a radio bot's own OAuth2 invite
|
||||
(server-26#225 S6, owner decision Q1 = per-bot OAuth).
|
||||
|
||||
GET /discord/oauth/options org owner / platform admin — which bot Connect
|
||||
would invite (no state minted). 404 = off.
|
||||
POST /discord/oauth/start org owner / platform admin — mint a state,
|
||||
return Discord's authorize URL
|
||||
POST /discord/oauth/complete signed-in user who started it — exchange the
|
||||
code, link the server, check permissions
|
||||
|
||||
Platform admins may pass ?org_id= to act for another org (resolve_org_scope).
|
||||
Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
|
||||
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
|
||||
|
||||
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
|
||||
radio bot preferred for the node's system (``node_id``); else the org's own
|
||||
bring-your-own bot; else the pool bot with the fewest servers that is under
|
||||
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
|
||||
secret set count. A bot the caller's org can't use is a 404, never a 403.
|
||||
|
||||
The org a server links to is the one the STATE names, re-checked against a
|
||||
fresh read of the caller's membership; the server is the one Discord's code
|
||||
exchange names. The redirect's ``guild_id`` query parameter is ignored.
|
||||
|
||||
This reverses ``discord_web``'s old "C2 never calls Discord on the browser's
|
||||
behalf" on purpose (plan finding 5): the call is made with the bot
|
||||
application's credentials, after the org-scope checks and rate limits here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.internal import discord_guild_link as glink
|
||||
from app.internal import discord_leases as leases
|
||||
from app.internal import discord_oauth as oauth
|
||||
from app.internal import discord_rest
|
||||
from app.internal import discord_streams as streams
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.audit import write_audit
|
||||
from app.internal.auth import (
|
||||
_RateLimiter, describe_actor, get_role, require_firebase_token, require_org_owner_token, resolve_org_scope,
|
||||
)
|
||||
from app.internal.logger import logger
|
||||
from app.routers.discord_checks import voice_check
|
||||
|
||||
router = APIRouter(prefix="/discord/oauth", tags=["discord"])
|
||||
|
||||
start_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
complete_limiter = _RateLimiter(max_calls=10, window_seconds=600)
|
||||
_ID = r"^[A-Za-z0-9_-]{1,64}$"
|
||||
_OFF = "Connecting a Discord server through DRB isn't available."
|
||||
|
||||
|
||||
def _require_enabled() -> None:
|
||||
if not oauth.enabled():
|
||||
raise HTTPException(404, _OFF)
|
||||
|
||||
|
||||
def _err(status: int, code: str, message: str, **extra) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"detail": message, "code": code, **extra})
|
||||
|
||||
|
||||
def _server_count(tok: dict) -> Optional[int]:
|
||||
members = tok.get("guild_ids")
|
||||
return len(members) if isinstance(members, list) else None
|
||||
|
||||
|
||||
def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
|
||||
return (tok.get("token_id") in oauth_ids
|
||||
and tok.get("valid") is not False
|
||||
and bool(tok.get("application_id"))
|
||||
and tok.get("org_id") in (None, org_id)
|
||||
and leases.invitable(tok))
|
||||
|
||||
|
||||
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
|
||||
token_id: Optional[str] = None) -> tuple[dict, str]:
|
||||
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
|
||||
``pool``. Raises HTTPException 404 when nothing fits."""
|
||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||
oauth_ids = await oauth.enabled_token_ids()
|
||||
|
||||
if token_id:
|
||||
tok = tokens.get(token_id)
|
||||
if not tok or tok.get("org_id") not in (None, org_id):
|
||||
raise HTTPException(404, "Radio bot not found.")
|
||||
if not _usable(tok, org_id, oauth_ids):
|
||||
raise HTTPException(404, f"{tok.get('name') or 'That radio bot'} can't be used to connect a server "
|
||||
"right now.")
|
||||
return tok, "chosen"
|
||||
|
||||
if node_id:
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node or node.get("org_id") != org_id:
|
||||
raise HTTPException(404, "Node not found.")
|
||||
system_id = streams._effective_system(node)
|
||||
system = await fstore.doc_get("systems", system_id) if system_id else None
|
||||
pref = tokens.get((system or {}).get("preferred_token_id") or "")
|
||||
if pref and _usable(pref, org_id, oauth_ids):
|
||||
return pref, "preferred"
|
||||
|
||||
own = sorted((t for t in tokens.values() if t.get("org_id") == org_id and _usable(t, org_id, oauth_ids)),
|
||||
key=lambda t: (str(t.get("name") or "").lower(), t["token_id"]))
|
||||
if own:
|
||||
return own[0], "org_bot"
|
||||
|
||||
cap = settings.discord_pool_bot_guild_cap
|
||||
pool = sorted((t for t in tokens.values() if t.get("org_id") is None and _usable(t, org_id, oauth_ids)),
|
||||
key=lambda t: (_server_count(t) if _server_count(t) is not None else cap,
|
||||
str(t.get("name") or "").lower(), t["token_id"]))
|
||||
if pool:
|
||||
return pool[0], "pool"
|
||||
raise HTTPException(404, _OFF)
|
||||
|
||||
|
||||
def _bot_summary(tok: dict, why: str) -> dict:
|
||||
return {"name": tok.get("name") or "DRB radio bot", "reason": why,
|
||||
"own_bot": tok.get("org_id") is not None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/options")
|
||||
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
|
||||
token_id: Optional[str] = Query(None, pattern=_ID),
|
||||
org_id: Optional[str] = None,
|
||||
decoded: dict = Depends(require_org_owner_token)):
|
||||
"""Whether Connect is offered here, and which bot it would invite."""
|
||||
_require_enabled()
|
||||
scope = resolve_org_scope(decoded, org_id)
|
||||
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id)
|
||||
return {"available": True, "bot": _bot_summary(tok, why)}
|
||||
|
||||
|
||||
class StartBody(BaseModel):
|
||||
node_id: Optional[str] = Field(None, pattern=_ID)
|
||||
token_id: Optional[str] = Field(None, pattern=_ID)
|
||||
return_to: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def oauth_start(body: StartBody, org_id: Optional[str] = None,
|
||||
decoded: dict = Depends(require_org_owner_token)):
|
||||
_require_enabled()
|
||||
scope = resolve_org_scope(decoded, org_id)
|
||||
uid, email = describe_actor(decoded)
|
||||
start_limiter.check(f"{uid}:discord_oauth_start")
|
||||
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id)
|
||||
return_to = oauth.safe_return_to(body.return_to)
|
||||
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
|
||||
node_id=body.node_id, return_to=return_to)
|
||||
await write_audit(uid, email, "discord.oauth.start", details={
|
||||
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id})
|
||||
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
|
||||
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
|
||||
|
||||
|
||||
class CompleteBody(BaseModel):
|
||||
# Lengths checked in the handler: a pydantic 422 would echo the code back.
|
||||
code: str
|
||||
state: str
|
||||
|
||||
|
||||
async def _still_owner(decoded: dict, uid: str, org_id: str) -> bool:
|
||||
"""Fresh read: the claims in an ID token can be up to an hour old."""
|
||||
if get_role(decoded) == "admin":
|
||||
return True
|
||||
member = await fstore.doc_get("org_members", uid)
|
||||
return bool(member and member.get("org_id") == org_id and member.get("org_role") == "owner")
|
||||
|
||||
|
||||
async def _channel_summary(guild_id: str, org_id: str, node_id: Optional[str]) -> Optional[dict]:
|
||||
"""Permissions right after the link, within discord_oauth_link_check_seconds.
|
||||
None = not answered in time (the picker's badges will say)."""
|
||||
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
|
||||
try:
|
||||
out = await asyncio.wait_for(
|
||||
voice_check(guild_id=guild_id, org_id=org_id, node_id=node_id,
|
||||
channels=link.get("voice_channels") or [], channel_id=None, refresh=True,
|
||||
with_token_ids=False),
|
||||
timeout=settings.discord_oauth_link_check_seconds)
|
||||
except Exception as e: # timeout or anything else: the link already stands
|
||||
logger.info(f"Discord OAuth link {guild_id}: permission check skipped ({type(e).__name__})")
|
||||
return None
|
||||
channels = [{"channel_id": c["channel_id"], "name": c.get("name"), "state": c["state"],
|
||||
"fix": next((b.get("fix") for b in c.get("bots") or [] if b.get("fix")), None)}
|
||||
for c in out.get("channels") or []]
|
||||
return {
|
||||
"enabled": out.get("enabled"),
|
||||
"channels_ready": sum(1 for c in channels if c["state"] == "ready"),
|
||||
"channels_blocked": sum(1 for c in channels if c["state"] == "needs_fix"),
|
||||
"channels": channels,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/complete")
|
||||
async def oauth_complete(body: CompleteBody, decoded: dict = Depends(require_firebase_token)):
|
||||
_require_enabled()
|
||||
uid, email = describe_actor(decoded)
|
||||
complete_limiter.check(f"{uid}:discord_oauth_complete")
|
||||
if not (0 < len(body.code) <= 200):
|
||||
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||
|
||||
# 1. Consume the state (single use; must be this caller's).
|
||||
try:
|
||||
state = await oauth.consume_state(body.state, uid)
|
||||
except oauth.StateError as e:
|
||||
await write_audit(uid, email, "discord.oauth.refused", details={"reason": e.code})
|
||||
return _err(e.status, e.code, e.message)
|
||||
org_id, token_id = state["org_id"], state["token_id"]
|
||||
return_to = state.get("return_to")
|
||||
|
||||
# 2. Still the owner of that org (fresh read, not the token's claims).
|
||||
if not await _still_owner(decoded, uid, org_id):
|
||||
await write_audit(uid, email, "discord.oauth.refused", details={"org_id": org_id, "reason": "not_owner"})
|
||||
return _err(403, "not_owner", "Only the organization owner can connect a Discord server.")
|
||||
|
||||
# 3. Exchange the code with that bot application's own secret.
|
||||
tok = await fstore.doc_get(leases.TOKENS, token_id)
|
||||
secret = await oauth.load_secret(token_id)
|
||||
if (not tok or not tok.get("application_id") or not secret
|
||||
or tok.get("org_id") not in (None, org_id) or tok.get("valid") is False):
|
||||
return _err(409, "bot_unavailable", "That radio bot can no longer connect servers. Start again from DRB.")
|
||||
client_id = str(tok["application_id"])
|
||||
r = await oauth.exchange_code(client_id, secret, body.code)
|
||||
if not r.ok or not isinstance(r.data, dict):
|
||||
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||
"org_id": org_id, "token_id": token_id, "reason": "exchange_failed", "status": r.status})
|
||||
if r.status is not None and 400 <= r.status < 500 and r.status != 429:
|
||||
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||
return _err(502, "discord_unavailable", "Couldn't reach Discord to finish connecting. Start again from DRB.")
|
||||
await oauth.revoke(client_id, secret, r.data.get("access_token"))
|
||||
|
||||
# 4. The server is the one Discord's exchange names — never the query string.
|
||||
guild = r.data.get("guild") if isinstance(r.data.get("guild"), dict) else None
|
||||
guild_id = str((guild or {}).get("id") or "")
|
||||
if not guild_id.isdigit():
|
||||
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||
"org_id": org_id, "token_id": token_id, "reason": "no_guild"})
|
||||
return _err(400, "no_guild", "Discord didn't say which server the bot was added to. Start again from DRB.")
|
||||
guild_name = guild.get("name") if isinstance(guild.get("name"), str) else None
|
||||
|
||||
# 5. Link + settle (a server another org linked is never moved: 409).
|
||||
try:
|
||||
result = await glink.link_and_settle(org_id, guild_id, guild_name, uid, "oauth",
|
||||
extra={"linked_with_token_id": token_id})
|
||||
except glink.LinkError as e:
|
||||
await write_audit(uid, email, "discord.guild.link_refused", details={
|
||||
"guild_id": guild_id, "org_id": org_id, "reason": e.code, "via": "oauth", "token_id": token_id})
|
||||
return _err(e.status, e.code, e.message, guild_name=guild_name, bot_name=tok.get("name"))
|
||||
|
||||
# 6. The bot is in the server now: record it, drop stale cached answers, audit.
|
||||
members = tok.get("guild_ids")
|
||||
if isinstance(members, list) and guild_id not in members:
|
||||
await fstore.doc_update(leases.TOKENS, token_id, {"guild_ids": [*members, guild_id]})
|
||||
discord_rest.invalidate(guild_id=guild_id)
|
||||
await write_audit(uid, email, "discord.guild.link", details={
|
||||
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||
"previous_org_id": result["previous_org_id"], "streams_stopped": len(result["streams_stopped"]),
|
||||
"via": "oauth", "token_id": token_id,
|
||||
})
|
||||
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} via OAuth (bot {token_id}) by uid={uid}")
|
||||
|
||||
# 7. Which voice channels are ready, in the same request (capped).
|
||||
check = await _channel_summary(guild_id, org_id, state.get("node_id"))
|
||||
return {
|
||||
"ok": True,
|
||||
"guild_id": guild_id,
|
||||
"guild_name": guild_name,
|
||||
"org_id": org_id,
|
||||
"bot_name": tok.get("name"),
|
||||
"already_linked": result["already_linked"],
|
||||
"streams_stopped": len(result["streams_stopped"]),
|
||||
"node_id": state.get("node_id"),
|
||||
"return_to": return_to,
|
||||
"checked": check is not None,
|
||||
"channels_ready": check["channels_ready"] if check else None,
|
||||
"channels_blocked": check["channels_blocked"] if check else None,
|
||||
"channels": check["channels"] if check else [],
|
||||
}
|
||||
@@ -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