Discord: per-bot OAuth "Connect a Discord server" backend (#225 S6) #260

Merged
logan merged 1 commits from feat/225-s6-oauth-link into main 2026-10-05 03:01:15 -04:00
Owner

Slice S6 of #225 (plan comment 3108). Per-bot OAuth "Connect a Discord server" backend, owner decision Q1. Built on main e54191a (S1–S4 + #227). Do not merge from here; the main session merges after review.

What it does

An org owner invites ONE radio bot through that bot's own OAuth2 invite. The state is a single-use, 10-minute nonce bound to org + user. Discord redirects back with code; C2 exchanges it with that bot application's client secret and links the server Discord's exchange names (the guild object). The redirect's guild_id query is ignored.

  • routers/discord_oauth.py (new): GET /discord/oauth/options, POST /discord/oauth/start, POST /discord/oauth/complete.
  • internal/discord_oauth.py (new): state mint/consume transaction, code exchange, token revoke, client-credentials secret check. Uses discord_rest's shared client (User-Agent, test transport) and counts 401/403/429 against its breaker.
  • discord_guild_link.py (plan finding 3): one transaction body _apply_link + one settle step _settle. /drb link redeem (redeem_and_settle), admin PUT /discord/guilds/{id}/link and OAuth (link_and_settle) all go through it. The org the server acted for before is now read inside the link transaction (streams.acting_org_of), not before it. Admin keeps its move-between-orgs override (allow_move=True); self-serve paths 409. The admin link now also writes an audit entry.
  • routers/tokens.py: _masked is an allowlist (+ oauth_enabled, server_count, server_cap). PUT /tokens/{id}/oauth (admin) checks the secret with Discord (client-credentials grant, token revoked) before storing; wrong secret → 400, Discord unreachable → 503, nothing stored. DELETE /tokens/{id}/oauth. Deleting a bot deletes its secret.
  • firestore.rules: explicit deny for bot_oauth and discord_oauth_states (the catch-all already denied them).

Secrets

bot_oauth/{token_id} = {client_secret, enabled, set_by, set_at, checked_at}, server-only, never on bot_tokens, never returned, never logged, never in an error string. Request models take the secret/code as plain str and length-check in the handler, because a pydantic 422 echoes the input back. No app-level encryption: the codebase has no pattern for it (bot tokens are stored the same way), so the secret relies on Firestore's at-rest encryption.

Before any secret is set (plan rollout notes)

  1. Firestore rules may not be live (#161 is open). With a non-admin ID token, read bot_tokens and bot_oauth and expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first.
  2. Spike: does a scope=bot code exchange return guild? The code assumes it does and returns 400 no_guild otherwise. Not verified against live Discord.
  3. Per bot app: register the redirect URI exactly, e.g. https://drb.cusano.net/discord/connected. Do not enable "Requires OAuth2 Code Grant".
  4. Set DISCORD_OAUTH_REDIRECT_URI to the same string. Empty = every route 404s.

Contract for S7 (OAuth UI)

Errors are JSON {detail, code}; detail is user-facing copy.

GET /discord/oauth/options?node_id=&token_id=&org_id= (org owner or platform admin; org_id is admin only). Mints nothing, so call it to decide whether to show the button.

  • 200 {available: true, bot: {name, reason, own_bot}}. reason is one of chosen | preferred | org_bot | pool.
  • 404 = OAuth off, or no usable bot: hide "Connect a Discord server".

POST /discord/oauth/start?org_id=, body {node_id?, token_id?, return_to?} (org owner or admin; 5 per 10 min per uid, then 429).

  • 200 {authorize_url, expires_at, bot: {name, reason, own_bot}}. Navigate to authorize_url (the discord.com invite with scope=bot, permissions=3146752, response_type=code, redirect_uri, state).
  • return_to must be a same-site path (/…, not //…, max 200 chars). Anything else is stored as null.
  • Bot order: explicit token_id the org may use → the radio bot preferred for node_id's system → the org's own bot (by name) → the pool bot with the fewest servers under discord_pool_bot_guild_cap. Only bots with an OAuth secret count. Another org's bot or node is a 404.

POST /discord/oauth/complete, body {code, state} (any signed-in user; must be the one who started it; 10 per 10 min per uid).

  • 200:
    {ok, guild_id, guild_name, org_id, bot_name, already_linked, streams_stopped,
     node_id, return_to, checked,
     channels_ready, channels_blocked,      // null when checked=false
     channels: [{channel_id, name, state, fix}]}
    
    state uses the S3 voice-check states: ready | needs_fix | unknown | busy | no_bot | bad_channel | unchecked. fix is the first fix text from the bots. checked=false means the 5 s permission check didn't finish; the link still stands, and the picker's badges will say.
  • Errors:
    • 400 invalid_state: unknown state.
    • 409 state_used: replayed.
    • 410 state_expired.
    • 403 wrong_user: someone else's state. The state is burned.
    • 403 not_owner: no longer the org's owner (fresh org_members read).
    • 409 bot_unavailable: the bot lost its secret, was marked invalid or was moved to another org.
    • 400 code_rejected: Discord refused the code.
    • 502 discord_unavailable.
    • 400 no_guild.
    • 409 linked_elsewhere: another org owns the server. Also carries guild_name and bot_name. The bot did join; nothing was linked.
  • Every complete burns the state, so on any error say "start again".
  • On error=access_denied in the callback, show "Nothing changed" without calling complete.

Admin (RadioBotsPanel):

  • PUT /tokens/{id}/oauth, body {client_secret} → {ok, token_id, oauth_enabled: true, redirect_uri_configured}.
    • 400: wrong or malformed secret.
    • 409: the bot has no application id yet; Re-check it first.
    • 503: Discord unreachable. Nothing was saved.
  • DELETE /tokens/{id}/oauth → {ok, token_id, oauth_enabled: false}.
  • GET /tokens rows add oauth_enabled, server_count (null when unknown) and server_cap (pool cap, null for BYO), for the "N of 100 servers" count.

Heads-up for S5/S7: _org_guilds (S5's file) lists a server only while the main bot hasn't marked bot_present: false. Until S5 lands, a server linked through OAuth with no main bot does show. It has no voice_channels directory, though, so the picker lists channels from the radio bot's snapshot (S3's fallback).

Tests

tests/test_discord_oauth.py (28 tests) covers:

  • the off switch;
  • bot choice: cap, preferred, BYO, cross-org 404;
  • the state: stored only as its hash, single use, wrong user (burned), expired;
  • the fresh-owner check;
  • the exchange: credentials, redirect URI, revoke, no-guild, rejected and unreachable;
  • the cross-org 409 and the fallback-org stop;
  • the permission-check timeout;
  • secret set/check/clear/delete, the allowlist, and no secret in any response, audit entry or log.

Existing link/admin tests pass unchanged through the shared helper.

Full suite: 838 passed, 4 warnings (~/venvs/drb-5c/bin/python -m pytest -q in drb-c2-core). flake8 --max-line-length=120 on the changed files is clean. No frontend files changed, so tsc wasn't run.

🤖 Generated with Claude Code

Slice **S6** of #225 (plan comment 3108). Per-bot OAuth "Connect a Discord server" backend, owner decision Q1. Built on main e54191a (S1–S4 + #227). **Do not merge from here; the main session merges after review.** ## What it does An org owner invites ONE radio bot through that bot's own OAuth2 invite. The `state` is a single-use, 10-minute nonce bound to org + user. Discord redirects back with `code`; C2 exchanges it with that bot application's client secret and links the server Discord's exchange names (the `guild` object). The redirect's `guild_id` query is ignored. - `routers/discord_oauth.py` (new): `GET /discord/oauth/options`, `POST /discord/oauth/start`, `POST /discord/oauth/complete`. - `internal/discord_oauth.py` (new): state mint/consume transaction, code exchange, token revoke, client-credentials secret check. Uses discord_rest's shared client (User-Agent, test transport) and counts 401/403/429 against its breaker. - `discord_guild_link.py` (plan finding 3): one transaction body `_apply_link` + one settle step `_settle`. `/drb link` redeem (`redeem_and_settle`), admin `PUT /discord/guilds/{id}/link` and OAuth (`link_and_settle`) all go through it. The org the server acted for before is now read **inside** the link transaction (`streams.acting_org_of`), not before it. Admin keeps its move-between-orgs override (`allow_move=True`); self-serve paths 409. The admin link now also writes an audit entry. - `routers/tokens.py`: `_masked` is an allowlist (+ `oauth_enabled`, `server_count`, `server_cap`). `PUT /tokens/{id}/oauth` (admin) checks the secret with Discord (client-credentials grant, token revoked) before storing; wrong secret → 400, Discord unreachable → 503, nothing stored. `DELETE /tokens/{id}/oauth`. Deleting a bot deletes its secret. - `firestore.rules`: explicit deny for `bot_oauth` and `discord_oauth_states` (the catch-all already denied them). ## Secrets `bot_oauth/{token_id}` = `{client_secret, enabled, set_by, set_at, checked_at}`, server-only, never on `bot_tokens`, never returned, never logged, never in an error string. Request models take the secret/code as plain `str` and length-check in the handler, because a pydantic 422 echoes the input back. **No app-level encryption:** the codebase has no pattern for it (bot tokens are stored the same way), so the secret relies on Firestore's at-rest encryption. ## Before any secret is set (plan rollout notes) 1. **Firestore rules may not be live (#161 is open).** With a non-admin ID token, read `bot_tokens` and `bot_oauth` and expect PERMISSION_DENIED. If that read succeeds, the bot tokens are already exposed, and that comes first. 2. Spike: does a `scope=bot` code exchange return `guild`? The code assumes it does and returns 400 `no_guild` otherwise. Not verified against live Discord. 3. Per bot app: register the redirect URI exactly, e.g. `https://drb.cusano.net/discord/connected`. Do **not** enable "Requires OAuth2 Code Grant". 4. Set `DISCORD_OAUTH_REDIRECT_URI` to the same string. Empty = every route 404s. ## Contract for S7 (OAuth UI) Errors are JSON `{detail, code}`; `detail` is user-facing copy. **`GET /discord/oauth/options?node_id=&token_id=&org_id=`** (org owner or platform admin; `org_id` is admin only). Mints nothing, so call it to decide whether to show the button. - 200 `{available: true, bot: {name, reason, own_bot}}`. `reason` is one of `chosen` | `preferred` | `org_bot` | `pool`. - 404 = OAuth off, or no usable bot: hide "Connect a Discord server". **`POST /discord/oauth/start?org_id=`**, body `{node_id?, token_id?, return_to?}` (org owner or admin; 5 per 10 min per uid, then 429). - 200 `{authorize_url, expires_at, bot: {name, reason, own_bot}}`. Navigate to `authorize_url` (the `discord.com` invite with `scope=bot`, `permissions=3146752`, `response_type=code`, `redirect_uri`, `state`). - `return_to` must be a same-site path (`/…`, not `//…`, max 200 chars). Anything else is stored as null. - Bot order: explicit `token_id` the org may use → the radio bot preferred for `node_id`'s system → the org's own bot (by name) → the pool bot with the fewest servers under `discord_pool_bot_guild_cap`. Only bots with an OAuth secret count. Another org's bot or node is a 404. **`POST /discord/oauth/complete`**, body `{code, state}` (any signed-in user; must be the one who started it; 10 per 10 min per uid). - 200: ``` {ok, guild_id, guild_name, org_id, bot_name, already_linked, streams_stopped, node_id, return_to, checked, channels_ready, channels_blocked, // null when checked=false channels: [{channel_id, name, state, fix}]} ``` `state` uses the S3 voice-check states: `ready` | `needs_fix` | `unknown` | `busy` | `no_bot` | `bad_channel` | `unchecked`. `fix` is the first fix text from the bots. `checked=false` means the 5 s permission check didn't finish; the link still stands, and the picker's badges will say. - Errors: - `400 invalid_state`: unknown state. - `409 state_used`: replayed. - `410 state_expired`. - `403 wrong_user`: someone else's state. The state is burned. - `403 not_owner`: no longer the org's owner (fresh `org_members` read). - `409 bot_unavailable`: the bot lost its secret, was marked invalid or was moved to another org. - `400 code_rejected`: Discord refused the code. - `502 discord_unavailable`. - `400 no_guild`. - `409 linked_elsewhere`: another org owns the server. Also carries `guild_name` and `bot_name`. The bot did join; nothing was linked. - Every complete burns the state, so on any error say "start again". - On `error=access_denied` in the callback, show "Nothing changed" without calling complete. **Admin (RadioBotsPanel):** - `PUT /tokens/{id}/oauth`, body `{client_secret}` → `{ok, token_id, oauth_enabled: true, redirect_uri_configured}`. - 400: wrong or malformed secret. - 409: the bot has no application id yet; Re-check it first. - 503: Discord unreachable. Nothing was saved. - `DELETE /tokens/{id}/oauth` → `{ok, token_id, oauth_enabled: false}`. - `GET /tokens` rows add `oauth_enabled`, `server_count` (null when unknown) and `server_cap` (pool cap, null for BYO), for the "N of 100 servers" count. **Heads-up for S5/S7:** `_org_guilds` (S5's file) lists a server only while the main bot hasn't marked `bot_present: false`. Until S5 lands, a server linked through OAuth with no main bot does show. It has no `voice_channels` directory, though, so the picker lists channels from the radio bot's snapshot (S3's fallback). ## Tests `tests/test_discord_oauth.py` (28 tests) covers: - the off switch; - bot choice: cap, preferred, BYO, cross-org 404; - the state: stored only as its hash, single use, wrong user (burned), expired; - the fresh-owner check; - the exchange: credentials, redirect URI, revoke, no-guild, rejected and unreachable; - the cross-org 409 and the fallback-org stop; - the permission-check timeout; - secret set/check/clear/delete, the allowlist, and no secret in any response, audit entry or log. Existing link/admin tests pass unchanged through the shared helper. Full suite: `838 passed, 4 warnings` (`~/venvs/drb-5c/bin/python -m pytest -q` in drb-c2-core). flake8 `--max-line-length=120` on the changed files is clean. No frontend files changed, so tsc wasn't run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 02:53:48 -04:00
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>
logan merged commit 2c254c1f57 into main 2026-10-05 03:01:15 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#260