Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,23 @@ S3_SECRET_ACCESS_KEY=
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=false
S3_PRESIGN_TTL=15m

# Discord Account Linking (OAuth2 on the backend; invite-bot runs as a separate server)
DISCORD_ENABLED=false
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# redirect_uri must be registered verbatim in the Discord Developer Portal (OAuth2 > Redirects).
DISCORD_REDIRECT_URI=http://localhost:8080/api/discord/callback
DISCORD_OAUTH_SCOPES=identify guilds.join
DISCORD_STATE_TTL=5m
DISCORD_OAUTH_TIMEOUT=10s
# Frontend page to return to after the OAuth callback.
DISCORD_SUCCESS_REDIRECT=https://smctf.example.com/profile
# Guild invite shown to users not yet in the guild (fallback button).
DISCORD_INVITE_URL=
# Auto-join the guild via guilds.join scope after linking.
DISCORD_AUTO_JOIN=true
# Connection to the separate invite-bot server (DISCORD_BOT_SECRET must equal the bot's DISCORD_INTERNAL_SECRET).
DISCORD_BOT_BASE_URL=http://localhost:8083
DISCORD_BOT_SECRET=change-me
DISCORD_BOT_TIMEOUT=5s
16 changes: 15 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"smctf/internal/cache"
"smctf/internal/config"
"smctf/internal/db"
"smctf/internal/discord"
httpserver "smctf/internal/http"
"smctf/internal/logging"
"smctf/internal/realtime"
Expand Down Expand Up @@ -85,6 +86,7 @@ func main() {
scoreRepo := repo.NewScoreboardRepo(database)
appConfigRepo := repo.NewAppConfigRepo(database)
vmRepo := repo.NewVMRepo(database)
discordRepo := repo.NewDiscordRepo(database)

var fileStore storage.ChallengeFileStore
if cfg.S3.Enabled {
Expand All @@ -107,6 +109,18 @@ func main() {
vmClient := vm.NewClient(cfg.VM.OrchestratorBaseURL, cfg.VM.OrchestratorSecret, cfg.VM.OrchestratorTimeout)
vmSvc := service.NewVMService(cfg.VM, vmRepo, challengeRepo, submissionRepo, vmClient, redisClient)

var discordSvc *service.DiscordService
if cfg.Discord.Enabled {
discordBotClient := discord.NewBotClient(cfg.Discord.BotBaseURL, cfg.Discord.BotSecret, cfg.Discord.BotTimeout)
discordOAuthClient := discord.NewOAuthClient(discord.OAuthConfig{
ClientID: cfg.Discord.ClientID,
ClientSecret: cfg.Discord.ClientSecret,
RedirectURI: cfg.Discord.RedirectURI,
Scopes: cfg.Discord.Scopes,
}, cfg.Discord.OAuthTimeout)
discordSvc = service.NewDiscordService(cfg.Discord, discordRepo, discordBotClient, discordOAuthClient, redisClient)
}

bootstrap.BootstrapAdmin(ctx, cfg, database, userRepo, teamRepo, divisionRepo, logger)

if cfg, _, _, err := appConfigSvc.Get(ctx); err != nil {
Expand All @@ -122,7 +136,7 @@ func main() {
leaderboardBus := realtime.NewScoreboardBus(redisClient, cfg, scoreSvc, divisionSvc, logger, sseHub)
leaderboardBus.Start(ctx)

router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, redisClient, logger, sseHub)
router := httpserver.NewRouter(cfg, authSvc, ctfSvc, appConfigSvc, userSvc, scoreSvc, divisionSvc, teamSvc, vmSvc, discordSvc, redisClient, logger, sseHub)
srv := &nethttp.Server{
Addr: cfg.HTTPAddr,
Handler: router,
Expand Down
1 change: 1 addition & 0 deletions codecov.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ comment:
require_changes: false

ignore:
- "invite-bot/**" # standalone invite bot server (TypeScript), not part of Go coverage
- "**/*_test.go"
- "**/mock/**"
- "cmd/**"
Expand Down
224 changes: 224 additions & 0 deletions docs/docs/discord.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
---
title: Discord
nav_order: 12
---

Notes:

- Discord account linking uses the OAuth2 authorization-code flow on the backend (`identify`, optionally `guilds.join`).
- Guild membership and role changes are performed by a separate **invite-bot** server over an internal HTTP API; the backend never holds the Discord bot token. The bot token, guild id, and verified-role id live on the bot server only.
- Access/refresh tokens are **not** stored. Role granting uses the bot token on the bot server, not the user's OAuth token.
- The acting user is identified by the JWT `access_token` cookie. `connect`, `callback`, and `status` require login; `sync-role` and `unlink` additionally require an unblocked (active) user.
- For authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests, send both `csrf_token` cookie and matching `X-CSRF-Token` header.
- All endpoints return `503 discord feature disabled` when `DISCORD_ENABLED=false`.

## Discord Status Schema

`discordStatusResponse` fields (omitted when empty):

- `connected`: whether the current user has a linked Discord account.
- `discord_user_id`: linked Discord user id (snowflake).
- `discord_username`: Discord unique handle.
- `discord_global_name`: Discord display name.
- `discord_avatar`: Discord avatar hash.
- `role_status`: one of `CONNECTED`, `VERIFIED`, `NOT_IN_GUILD`, `ROLE_FAILED`, `REVOKED`, `LEFT_GUILD`.
- `connected_at`, `verified_at`: timestamps (UTC).
- `invite_url`: guild invite link to show users who are not yet in the guild (from `DISCORD_INVITE_URL`).

## Start Linking

`GET /api/discord/connect`

Headers

```
Cookie: access_token=<jwt>
```

Response 302 — redirect to the Discord authorize URL. The backend generates a single-use `state` (random, TTL `DISCORD_STATE_TTL`) bound to the current user and stores it in Redis.

```
Location: https://discord.com/oauth2/authorize?response_type=code&client_id=...&scope=identify+guilds.join&state=...&redirect_uri=...&prompt=consent
```

Errors:

- 401 `invalid token` or `missing access_token cookie`
- 503 `discord feature disabled`
- 500 internal error

---

## OAuth Callback

`GET /api/discord/callback?code=<code>&state=<state>`

Headers

```
Cookie: access_token=<jwt>
```

Response 302 — redirect to `DISCORD_SUCCESS_REDIRECT` with a `?discord=<result>` query. If `DISCORD_SUCCESS_REDIRECT` is empty, returns `200 {"discord":"<result>"}` instead.

`<result>` values:

- `verified`: account linked, joined the guild, verified role granted.
- `connected_not_joined`: linked, but not a guild member (`NOT_IN_GUILD` / `LEFT_GUILD`).
- `role_failed`: linked, but the role could not be granted (bot permission / role hierarchy).
- `already_linked`: that Discord account is already linked to another user.
- `state_invalid`: `state` was missing, expired, forged, or did not match the user.
- `error`: token exchange / profile fetch failed, or another unexpected error.

```
Location: https://smctf.example.com/profile?discord=verified
```

Callback behavior:

- `state` is validated with `GETDEL` and must match the user that started `connect` (CSRF protection); invalid `state` redirects with `discord=state_invalid`.
- The code is exchanged for an access token, then `/users/@me` is read to capture `discord_user_id`, `username`, `global_name`, `avatar`.
- The connection is upserted. `discord_user_id` is unique — reusing one already linked elsewhere redirects with `discord=already_linked`.
- When `DISCORD_AUTO_JOIN=true`, the backend asks the invite-bot to add the user to the guild (`guilds.join`) using the access token, which is forwarded once and never stored.
- The verified role is then requested through the invite-bot; `role_status` is persisted as `VERIFIED`, `NOT_IN_GUILD`, or `ROLE_FAILED`.

Errors:

- 401 `invalid token` or `missing access_token cookie`
- 503 `discord feature disabled`

(Other failures are surfaced as the `?discord=<result>` redirect query above, not as HTTP error codes.)

---

## Get Link Status

`GET /api/discord/status`

Headers

```
Cookie: access_token=<jwt>
```

Response 200 — linked

```json
{
"connected": true,
"discord_user_id": "900000000000000001",
"discord_username": "neo",
"discord_global_name": "Neo",
"discord_avatar": "a1b2c3d4e5f6",
"role_status": "VERIFIED",
"connected_at": "2026-06-23T11:00:00Z",
"verified_at": "2026-06-23T11:00:02Z",
"invite_url": "https://discord.gg/example"
}
```

Response 200 — not linked

```json
{
"connected": false,
"invite_url": "https://discord.gg/example"
}
```

Errors:

- 401 `invalid token` or `missing access_token cookie`
- 503 `discord feature disabled`
- 500 internal error

---

## Re-Check Role

`POST /api/discord/sync-role`

Headers

```
Cookie: access_token=<jwt>
X-CSRF-Token: <csrf>
```

Re-attempts guild join / verified-role grant for an already-linked user (used after the user joins the guild). Returns the updated `discordStatusResponse`.

Response 200 — same schema as `GET /api/discord/status` (linked).

Errors:

- 401 `invalid token` or `missing access_token cookie`
- 403 `user blocked`
- 404 `discord account not connected`
- 429 `discord rate limited`
- 503 `discord feature disabled` or `discord bot server unavailable`
- 500 internal error

---

## Unlink

`DELETE /api/discord/unlink`

Headers

```
Cookie: access_token=<jwt>
X-CSRF-Token: <csrf>
```

Response 200

```json
{
"status": "ok"
}
```

Unlink behavior:

- The invite-bot is asked to kick the user from the guild. This is best-effort: any error (bot down, missing permission, already gone) is logged and ignored.
- The connection row is then deleted.

Errors:

- 401 `invalid token` or `missing access_token cookie`
- 403 `user blocked`
- 404 `discord account not connected`
- 503 `discord feature disabled`
- 500 internal error

---

## Configuration (ENV)

Discord service options (backend):

- `DISCORD_ENABLED` (default: `false`)
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URI` — must match a redirect registered in the Discord Developer Portal exactly.
- `DISCORD_OAUTH_SCOPES` (default: `identify guilds.join`)
- `DISCORD_STATE_TTL` (default: `5m`)
- `DISCORD_OAUTH_TIMEOUT` (default: `10s`)
- `DISCORD_SUCCESS_REDIRECT` — frontend page to return to after the callback.
- `DISCORD_INVITE_URL` — guild invite shown to users not yet in the guild.
- `DISCORD_AUTO_JOIN` (default: `true`)
- `DISCORD_BOT_BASE_URL` (default: `http://localhost:8083`)
- `DISCORD_BOT_SECRET` — shared bearer secret for the invite-bot internal API (must equal the bot's `DISCORD_INTERNAL_SECRET`).
- `DISCORD_BOT_TIMEOUT` (default: `5s`)

Validation rules when `DISCORD_ENABLED=true`:

- `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` must be set
- `DISCORD_REDIRECT_URI` must not be empty
- `DISCORD_OAUTH_SCOPES` must not be empty
- `DISCORD_BOT_BASE_URL` must not be empty
- `DISCORD_STATE_TTL > 0`
- `DISCORD_BOT_TIMEOUT > 0`
- `DISCORD_OAUTH_TIMEOUT > 0`

The Discord bot token, guild id, and verified-role id are configured on the invite-bot server, not here.
Loading
Loading