Discord OAuth + session for BOT_ADMINS #545

Open
opened 2026-09-29 19:08:35 +01:00 by Smithy-bot · 2 comments
Member

Epic: #543
Story Points: 5


SO THAT only Discord users in BOT_ADMINS can access the management UI and API.

Implement Discord OAuth login and session handling for the Management API/UI. Reject non-admins after OAuth.

Scope

Pairs with #544: provides the admin session that requireManagementAuth consumes. Public auth routes mount on the public router from #544; protected /api/* routes require a valid session whose Discord user ID is in BOT_ADMINS.

In scope: Discord OAuth2 (authorization code), server-side session, login/logout/callback routes, session cookie, post-OAuth BOT_ADMINS gate, integration with management auth middleware.

Out of scope: React login page polish (#546 may link to login); multi-role RBAC (#551); serving SPA static assets (#546).

Acceptance criteria

  • GIVEN required env vars are set (DISCORD_OAUTH_CLIENT_ID, DISCORD_OAUTH_CLIENT_SECRET, SESSION_SECRET, and a public base URL such as MANAGEMENT_PUBLIC_URL for OAuth redirect construction)
    WHEN the bot process starts
    THEN OAuth routes are registered on the public router (not behind requireManagementAuth).

  • GIVEN an unauthenticated browser
    WHEN they GET /auth/discord (login entry)
    THEN they are redirected to Discord OAuth with correct client_id, redirect_uri, and scopes sufficient to read the user's Discord ID (identify minimum).

  • GIVEN Discord returns an authorization code to GET /auth/discord/callback
    WHEN the server exchanges the code and loads the Discord user profile
    THEN a server-side session is created containing at least discordUserId (string snowflake).

  • GIVEN the authenticated Discord user ID is listed in BOT_ADMINS
    WHEN OAuth completes
    THEN the session is kept, the user is redirected to the management UI entry (or SPA root path), and subsequent requests include a session cookie accepted by #544 middleware.

  • GIVEN the Discord user ID is not in BOT_ADMINS
    WHEN OAuth completes
    THEN the session is destroyed or marked invalid, the user sees a clear forbidden/denied response, and protected /api/* routes return 401 or 403.

  • GIVEN an admin session exists
    WHEN they call POST /auth/logout
    THEN the session is cleared and protected routes no longer accept the old cookie.

  • GIVEN production deployment behind HTTPS (reverse proxy)
    WHEN sessions are issued
    THEN cookies are HttpOnly, use an appropriate SameSite for OAuth (e.g. Lax), and Secure in production; the session secret is not logged.

  • GIVEN automated tests for auth
    WHEN CI runs
    THEN tests cover: OAuth callback creates session for a BOT_ADMINS user; non-admin rejected; logout clears session; session shape is compatible with #544 requireManagementAuth integration tests.

Subtasks

  • Add env configuration and validation for Discord OAuth, session secret, and public base URL
  • Implement Discord OAuth2 authorize + callback handlers on the public router (/auth/discord, /auth/discord/callback)
  • Implement server-side session store (in-process v1) with documented session fields (discordUserId; optional display name/avatar for UI)
  • Implement BOT_ADMINS membership check after OAuth; deny non-admins
  • Implement POST /auth/logout and session destruction
  • Wire session into requireManagementAuth from #544 (shared session reader / types)
  • Add unit or integration tests for admin vs non-admin OAuth outcomes and logout
  • Document Discord app redirect URI setup and required env vars (full deploy doc in #553; minimal env list acceptable here)

Notes

  • Decision: #542 — Discord OAuth; v1 gated by BOT_ADMINS
  • Will need Discord app OAuth client id/secret env vars
  • Release: Ship with #544 before enabling production-only protection on /api/reload-db
  • Depends on: #544 public vs protected router split; enables end-to-end stage testing of #544 admin reload flow
Epic: #543 Story Points: 5 --- SO THAT only Discord users in `BOT_ADMINS` can access the management UI and API. Implement Discord OAuth login and session handling for the Management API/UI. Reject non-admins after OAuth. ## Scope Pairs with [#544](https://git.vylpes.xyz/External/card-drop/issues/544): provides the **admin session** that `requireManagementAuth` consumes. Public auth routes mount on the public router from #544; protected `/api/*` routes require a valid session whose Discord user ID is in `BOT_ADMINS`. **In scope:** Discord OAuth2 (authorization code), server-side session, login/logout/callback routes, session cookie, post-OAuth `BOT_ADMINS` gate, integration with management auth middleware. **Out of scope:** React login page polish (#546 may link to login); multi-role RBAC ([#551](https://git.vylpes.xyz/External/card-drop/issues/551)); serving SPA static assets ([#546](https://git.vylpes.xyz/External/card-drop/issues/546)). ## Acceptance criteria - **GIVEN** required env vars are set (`DISCORD_OAUTH_CLIENT_ID`, `DISCORD_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, and a public base URL such as `MANAGEMENT_PUBLIC_URL` for OAuth redirect construction) **WHEN** the bot process starts **THEN** OAuth routes are registered on the **public** router (not behind `requireManagementAuth`). - **GIVEN** an unauthenticated browser **WHEN** they `GET /auth/discord` (login entry) **THEN** they are redirected to Discord OAuth with correct `client_id`, `redirect_uri`, and scopes sufficient to read the user's Discord ID (`identify` minimum). - **GIVEN** Discord returns an authorization code to `GET /auth/discord/callback` **WHEN** the server exchanges the code and loads the Discord user profile **THEN** a server-side session is created containing at least `discordUserId` (string snowflake). - **GIVEN** the authenticated Discord user ID is listed in `BOT_ADMINS` **WHEN** OAuth completes **THEN** the session is kept, the user is redirected to the management UI entry (or SPA root path), and subsequent requests include a session cookie accepted by #544 middleware. - **GIVEN** the Discord user ID is **not** in `BOT_ADMINS` **WHEN** OAuth completes **THEN** the session is destroyed or marked invalid, the user sees a clear forbidden/denied response, and protected `/api/*` routes return **401** or **403**. - **GIVEN** an admin session exists **WHEN** they call `POST /auth/logout` **THEN** the session is cleared and protected routes no longer accept the old cookie. - **GIVEN** production deployment behind HTTPS (reverse proxy) **WHEN** sessions are issued **THEN** cookies are `HttpOnly`, use an appropriate `SameSite` for OAuth (e.g. Lax), and `Secure` in production; the session secret is not logged. - **GIVEN** automated tests for auth **WHEN** CI runs **THEN** tests cover: OAuth callback creates session for a `BOT_ADMINS` user; non-admin rejected; logout clears session; session shape is compatible with #544 `requireManagementAuth` integration tests. ## Subtasks - [ ] Add env configuration and validation for Discord OAuth, session secret, and public base URL - [ ] Implement Discord OAuth2 authorize + callback handlers on the public router (`/auth/discord`, `/auth/discord/callback`) - [ ] Implement server-side session store (in-process v1) with documented session fields (`discordUserId`; optional display name/avatar for UI) - [ ] Implement `BOT_ADMINS` membership check after OAuth; deny non-admins - [ ] Implement `POST /auth/logout` and session destruction - [ ] Wire session into `requireManagementAuth` from #544 (shared session reader / types) - [ ] Add unit or integration tests for admin vs non-admin OAuth outcomes and logout - [ ] Document Discord app redirect URI setup and required env vars (full deploy doc in #553; minimal env list acceptable here) ## Notes - Decision: [#542](https://git.vylpes.xyz/External/card-drop/issues/542) — Discord OAuth; v1 gated by `BOT_ADMINS` - Will need Discord app OAuth client id/secret env vars - **Release:** Ship with #544 before enabling production-only protection on `/api/reload-db` - Depends on: #544 public vs protected router split; enables end-to-end stage testing of #544 admin reload flow
Smithy-bot added this to the 0.14.0 milestone 2026-09-29 19:08:35 +01:00
Smithy-bot stopped working 2026-10-22 12:02:18 +01:00
13 seconds
Smithy-bot stopped working 2026-10-23 12:05:17 +01:00
12 seconds
Author
Member

Story point estimate: 5

Factor Notes
OAuth2 authorize + callback + token exchange Standard flow; Discord API integration
Session store + cookie policy In-process v1; production Secure / SameSite
BOT_ADMINS gate + logout Straightforward membership check
Integration with #544 requireManagementAuth Shared session reader; must align with existing middleware tests
Automated tests Mock/stub Discord + session scenarios per AC
Env validation Four+ required vars

Comparable scope to #544 (also 5). Eight subtasks already split implementation, test, and minimal docs work.

→ Moving to needs/tests.

### Story point estimate: **5** | Factor | Notes | |--------|--------| | OAuth2 authorize + callback + token exchange | Standard flow; Discord API integration | | Session store + cookie policy | In-process v1; production `Secure` / `SameSite` | | `BOT_ADMINS` gate + logout | Straightforward membership check | | Integration with #544 `requireManagementAuth` | Shared session reader; must align with existing middleware tests | | Automated tests | Mock/stub Discord + session scenarios per AC | | Env validation | Four+ required vars | Comparable scope to [#544](https://git.vylpes.xyz/External/card-drop/issues/544) (also **5**). Eight subtasks already split implementation, test, and minimal docs work. → Moving to `needs/tests`.
Smithy-bot added spent time 2026-10-23 12:05:38 +01:00
2 minutes
Author
Member

Planning — needs/tests → needs/approval

QA scripts mapped to the acceptance criteria in the issue description. Run on a branch with this story and #544 on stage; automated rows via yarn test locally/CI.

Preconditions

  • Bot process with management HTTP enabled (EXPRESS_PORT / same entry as #544).
  • Env set: DISCORD_OAUTH_CLIENT_ID, DISCORD_OAUTH_CLIENT_SECRET, SESSION_SECRET, MANAGEMENT_PUBLIC_URL (or equivalent public base for redirect URI).
  • BOT_ADMINS includes at least one Discord user ID for “admin” OAuth tests.
  • Second Discord account not in BOT_ADMINS for denial tests.
  • Discord OAuth app redirect URI registered for {MANAGEMENT_PUBLIC_URL}/auth/discord/callback (or implemented path).
  • Browser or HTTP client with cookie jar; yarn test for automated suite.

AC1 — OAuth routes on public router when env is configured

ID Steps Expected
T1.1 Start bot with all required OAuth/session env vars set Process starts; no crash from missing config
T1.2 Code review / route registration /auth/* routes mount on public router from #544, not behind requireManagementAuth
T1.3 GET /auth/discord without session Reaches OAuth handler (redirect), not 401 from management middleware

AC2 — Login entry redirects to Discord OAuth

ID Steps Expected
T2.1 GET /auth/discord (browser or follow redirects disabled) 302 to discord.com OAuth authorize URL
T2.2 Inspect redirect query params client_id matches env; redirect_uri matches configured callback; scopes include identify (minimum)
T2.3 Invalid/missing OAuth env at startup Clear startup failure or disabled auth (per implementation); documented behaviour

AC3 — Callback exchanges code and creates session

ID Steps Expected
T3.1 Complete Discord OAuth as a test user (stage) or integration test with mocked Discord token/user APIs GET /auth/discord/callback?code=… succeeds
T3.2 Inspect session store / cookie after callback Session contains discordUserId (snowflake string)
T3.3 Invalid/expired code Error handled; no partial admin session left

AC4 — BOT_ADMINS member completes login successfully

ID Steps Expected
T4.1 OAuth as Discord user whose ID is in BOT_ADMINS Session kept; session cookie set
T4.2 After redirect to management UI entry / SPA root Page loads or redirects appropriately (not forbidden)
T4.3 POST /api/reload-db (or other protected /api/* from #544) with session cookie 200 for admin (pairs with #544 AC4)

AC5 — Non-admin rejected after OAuth

ID Steps Expected
T5.1 OAuth as Discord user not in BOT_ADMINS Clear forbidden/denied UX (page or JSON)
T5.2 Session after denial Destroyed or invalid; cookie does not grant API access
T5.3 POST /api/reload-db with post-denial cookie 401 or 403; reload not invoked

AC6 — Logout clears session

ID Steps Expected
T6.1 With valid admin session, POST /auth/logout 200 or 204; session cleared server-side
T6.2 Repeat protected API call with old cookie 401 or 403
T6.3 GET /auth/discord after logout Starts fresh OAuth flow (no stale admin session)

ID Steps Expected
T7.1 Issue session in production/stage-with-HTTPS config Set-Cookie includes HttpOnly; Secure when HTTPS; SameSite appropriate for OAuth (e.g. Lax)
T7.2 Logs during OAuth/login SESSION_SECRET and client secret not logged
T7.3 Code review Session secret read from env only

AC8 — Automated tests in CI

ID Steps Expected
U1 yarn test — OAuth/session auth tests Green
U2 Test: mocked callback for BOT_ADMINS user Session created; middleware accepts shape for #544
U3 Test: mocked callback for non-admin Rejected; protected route returns 401/403
U4 Test: logout clears session Subsequent protected request unauthorized
U5 Forgejo Actions / CI on PR Test job passes

Subtasks / documentation / regression

ID Steps Expected
D1 Env list in issue/README (minimal) Documents Discord app setup + redirect URI
D2 Cross-check #553 deploy doc (when available) OAuth redirect URI and env align
R1 Discord bot commands / non-auth HTTP Unchanged aside from new /auth/* routes
R2 Coordinate release with #544 Do not enable production-only /api protection before this story is live on target env

Sign-off checklist

  • T1–T7, U1–U5, D1–D2, and R1–R2 pass on stage (or mocks as noted).
  • End-to-end admin reload via OAuth session verified with #544.

Moving to needs/approval for your review before implementation. @Vylpes

## Planning — needs/tests → needs/approval QA scripts mapped to the acceptance criteria in the issue description. Run on a branch with this story **and** [#544](https://git.vylpes.xyz/External/card-drop/issues/544) on **stage**; automated rows via `yarn test` locally/CI. ### Preconditions - Bot process with management HTTP enabled (`EXPRESS_PORT` / same entry as #544). - Env set: `DISCORD_OAUTH_CLIENT_ID`, `DISCORD_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, `MANAGEMENT_PUBLIC_URL` (or equivalent public base for redirect URI). - `BOT_ADMINS` includes at least one Discord user ID for “admin” OAuth tests. - Second Discord account **not** in `BOT_ADMINS` for denial tests. - Discord OAuth app redirect URI registered for `{MANAGEMENT_PUBLIC_URL}/auth/discord/callback` (or implemented path). - Browser or HTTP client with cookie jar; `yarn test` for automated suite. --- ### AC1 — OAuth routes on public router when env is configured | ID | Steps | Expected | |----|--------|----------| | T1.1 | Start bot with all required OAuth/session env vars set | Process starts; no crash from missing config | | T1.2 | Code review / route registration | `/auth/*` routes mount on **public** router from #544, not behind `requireManagementAuth` | | T1.3 | `GET /auth/discord` without session | Reaches OAuth handler (redirect), not **401** from management middleware | --- ### AC2 — Login entry redirects to Discord OAuth | ID | Steps | Expected | |----|--------|----------| | T2.1 | `GET /auth/discord` (browser or follow redirects disabled) | **302** to `discord.com` OAuth authorize URL | | T2.2 | Inspect redirect query params | `client_id` matches env; `redirect_uri` matches configured callback; scopes include `identify` (minimum) | | T2.3 | Invalid/missing OAuth env at startup | Clear startup failure or disabled auth (per implementation); documented behaviour | --- ### AC3 — Callback exchanges code and creates session | ID | Steps | Expected | |----|--------|----------| | T3.1 | Complete Discord OAuth as a test user (stage) or integration test with mocked Discord token/user APIs | `GET /auth/discord/callback?code=…` succeeds | | T3.2 | Inspect session store / cookie after callback | Session contains `discordUserId` (snowflake string) | | T3.3 | Invalid/expired `code` | Error handled; no partial admin session left | --- ### AC4 — `BOT_ADMINS` member completes login successfully | ID | Steps | Expected | |----|--------|----------| | T4.1 | OAuth as Discord user whose ID is in `BOT_ADMINS` | Session kept; session cookie set | | T4.2 | After redirect to management UI entry / SPA root | Page loads or redirects appropriately (not forbidden) | | T4.3 | `POST /api/reload-db` (or other protected `/api/*` from #544) with session cookie | **200** for admin (pairs with #544 AC4) | --- ### AC5 — Non-admin rejected after OAuth | ID | Steps | Expected | |----|--------|----------| | T5.1 | OAuth as Discord user **not** in `BOT_ADMINS` | Clear forbidden/denied UX (page or JSON) | | T5.2 | Session after denial | Destroyed or invalid; cookie does not grant API access | | T5.3 | `POST /api/reload-db` with post-denial cookie | **401** or **403**; reload not invoked | --- ### AC6 — Logout clears session | ID | Steps | Expected | |----|--------|----------| | T6.1 | With valid admin session, `POST /auth/logout` | **200** or **204**; session cleared server-side | | T6.2 | Repeat protected API call with old cookie | **401** or **403** | | T6.3 | `GET /auth/discord` after logout | Starts fresh OAuth flow (no stale admin session) | --- ### AC7 — Production cookie security | ID | Steps | Expected | |----|--------|----------| | T7.1 | Issue session in production/stage-with-HTTPS config | `Set-Cookie` includes `HttpOnly`; `Secure` when HTTPS; `SameSite` appropriate for OAuth (e.g. Lax) | | T7.2 | Logs during OAuth/login | `SESSION_SECRET` and client secret not logged | | T7.3 | Code review | Session secret read from env only | --- ### AC8 — Automated tests in CI | ID | Steps | Expected | |----|--------|----------| | U1 | `yarn test` — OAuth/session auth tests | Green | | U2 | Test: mocked callback for `BOT_ADMINS` user | Session created; middleware accepts shape for #544 | | U3 | Test: mocked callback for non-admin | Rejected; protected route returns **401**/**403** | | U4 | Test: logout clears session | Subsequent protected request unauthorized | | U5 | Forgejo Actions / CI on PR | Test job passes | --- ### Subtasks / documentation / regression | ID | Steps | Expected | |----|--------|----------| | D1 | Env list in issue/README (minimal) | Documents Discord app setup + redirect URI | | D2 | Cross-check #553 deploy doc (when available) | OAuth redirect URI and env align | | R1 | Discord bot commands / non-auth HTTP | Unchanged aside from new `/auth/*` routes | | R2 | Coordinate release with #544 | Do not enable production-only `/api` protection before this story is live on target env | --- ### Sign-off checklist - [ ] T1–T7, U1–U5, D1–D2, and R1–R2 pass on stage (or mocks as noted). - [ ] End-to-end admin reload via OAuth session verified with #544. Moving to **needs/approval** for your review before implementation. @Vylpes
Smithy-bot removed their assignment 2026-10-24 12:05:48 +01:00
Smithy-bot stopped working 2026-10-24 12:05:48 +01:00
18 seconds
Vylpes removed their assignment 2026-10-24 19:32:07 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Total time spent: 2 minutes 43 seconds
Smithy-bot
2 minutes 43 seconds
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Depends on
Reference
External/card-drop#545
No description provided.