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:
Logan Cusano
2026-10-05 02:52:57 -04:00
co-authored by Claude Opus 5.5
parent e54191a5d0
commit db4e326332
10 changed files with 1177 additions and 60 deletions
+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)