Merge pull request 'Discord: per-bot OAuth "Connect a Discord server" backend (#225 S6)' (#260) from feat/225-s6-oauth-link into main
Build & Deploy / Build & push images (push) Successful in 5m35s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 3s
Build & Deploy / Report a failed deploy (push) Skipped

This commit was merged in pull request #260.
This commit is contained in:
2026-10-05 03:01:14 -04:00
10 changed files with 1177 additions and 60 deletions
+109 -25
View File
@@ -27,6 +27,16 @@ Firestore layout:
unlinked_at|None, unlinked_by|None
Person names are never stored: the redeemer is kept as a Discord user id.
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
through ONE transaction body (``_apply_link``) and then runs ONE settle step
(``_settle``): a server that was acting for a different org (in practice the
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
stopped, because they were placed on that org's nodes. The org it acted for
before is read inside the same transaction as the write, so the stop decision
can't race a concurrent link.
"""
from __future__ import annotations
@@ -35,6 +45,7 @@ import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from app.internal import discord_streams as streams
from app.internal import firestore as fstore
CODES = "discord_link_codes"
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
"expires_minutes": CODE_TTL_MINUTES}
# Stop reasons the settle step stamps on the streams it stops.
RELINK_STOP_REASON = "relinked"
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
allow_move: bool = False, extra: Optional[dict] = None):
"""The guild-side half of every link, inside a transaction whose reads are
all done. Returns a LinkError (nothing written) or the result dict:
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
org the server acted for before (its link, else the fallback org).
``allow_move`` is the platform admin's support override; every self-serve
path refuses to move a server another org has linked."""
current = (guild or {}).get("org_id")
if current and current != org_id and not allow_move:
return LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")
iso = now.isoformat()
name = (guild_name or "").strip()[:100]
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": name or (guild or {}).get("guild_name"),
"linked_by": linked_by,
"linked_via": via,
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
**(extra or {}),
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"previous_org_id": streams.acting_org_of(guild)}
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
db = fstore.db
when = now or _now()
def _txn(txn):
guild_ref = db.collection(GUILDS).document(guild_id)
snap = guild_ref.get(transaction=txn)
guild = snap.to_dict() if snap.exists else None
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
return {"error": out} if isinstance(out, LinkError) else out
return _txn
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
"""Build the transaction body. Every read happens before any write."""
db = fstore.db
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
return {"error": LinkError(410, "code_expired",
"That link code has expired. Generate a new one in DRB → "
"Settings → Organization.")}
org_id = doc["org_id"]
current = (guild or {}).get("org_id")
if current and current != org_id:
return {"error": LinkError(409, "linked_elsewhere",
"This server is already linked to a different DRB organization. "
"That organization has to unlink it first.")}
iso = now.isoformat()
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
if isinstance(out, LinkError):
return {"error": out} # the code is NOT consumed: its org can use it elsewhere
txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
"redeemed_by_discord_id": discord_user_id})
txn.set(guild_ref, {
"guild_id": guild_id,
"org_id": org_id,
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
"linked_by": f"discord:{discord_user_id}",
"linked_via": "code",
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
"unlinked_at": None,
"unlinked_by": None,
# A server an admin removed (streams.remove_guild) comes back on link.
"removed_at": None,
"removed_by": None,
}, merge=True)
return {"org_id": org_id, "already_linked": current == org_id,
"created_by_uid": doc.get("created_by_uid")}
return {**out, "created_by_uid": doc.get("created_by_uid")}
return _txn
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.
Does NOT stop anything — routes call ``redeem_and_settle``."""
code = normalize(raw_code)
if len(code) != _CODE_LEN:
raise LinkError(404, "invalid_code",
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
return result
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
"""After a link committed: a server that was acting for another org (the
fallback org, or — admin override only — another linked org) has its live
radio channels stopped; they were placed on that org's nodes, and left
running the reconciler would keep re-placing them there
(server-26#225 plan finding 3: this lived in two routers before)."""
before = result.get("previous_org_id")
stopped: list = []
if before and before != result["org_id"]:
stopped = await streams.stop_guild(str(guild_id), stop_reason)
return {**result, "streams_stopped": stopped}
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
allow_move: bool = False, extra: Optional[dict] = None,
stop_reason: str = RELINK_STOP_REASON) -> dict:
"""Link a server to an org directly (admin, OAuth) and settle. Raises
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
result = await fstore.run_transaction(
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
if "error" in result:
raise result["error"]
return await _settle(guild_id, result, stop_reason)
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
discord_user_id: str) -> dict:
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
return await _settle(guild_id, result, RELINK_STOP_REASON)
async def list_org_guilds(org_id: str) -> list[dict]:
return [
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
+246
View File
@@ -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"]
+12 -6
View File
@@ -94,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]:
# Guild -> org
# ---------------------------------------------------------------------------
def acting_org_of(link: Optional[dict]) -> Optional[str]:
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
no doc). Pure, so a link transaction can decide from the snapshot it read
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
async def resolve_guild_org(guild_id: str) -> Optional[str]:
"""The org a Discord server acts for: its ``discord_guilds`` link, else
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
@@ -101,12 +112,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
gets no fallback: otherwise unlinking org B's server would quietly hand it
to the founding org."""
link = await fstore.doc_get(GUILDS, str(guild_id))
if link and link.get("org_id"):
return link["org_id"]
if link and link.get("unlinked_at"):
return None
return settings.discord_unlinked_guild_org_id or None
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
+2
View File
@@ -26,6 +26,7 @@ from app.internal import dynsec
from app.internal import firestore as fstore
from app.routers import discord_web
from app.routers import discord_checks
from app.routers import discord_oauth
async def _clear_legacy_token_flags():
@@ -169,6 +170,7 @@ app.include_router(discord_web.router)
app.include_router(discord_router.router)
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signed-in starter); 404 while OAuth is off — #225 S6
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing
+13 -18
View File
@@ -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
@@ -182,23 +182,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}")
+5 -7
View File
@@ -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)
+289
View File
@@ -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 [],
}
+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}