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