From d2b100778729d129764a942326c1728eec0f9d5b Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:22:38 +0100 Subject: [PATCH 01/16] docs: access control design for site-wide auth modes Covers five modes: public, password, steam, steamGroup, squadXml. Builds on existing role-based auth foundation (PR #311). --- .../plans/2026-03-08-access-control-design.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/plans/2026-03-08-access-control-design.md diff --git a/docs/plans/2026-03-08-access-control-design.md b/docs/plans/2026-03-08-access-control-design.md new file mode 100644 index 00000000..0fdae4f7 --- /dev/null +++ b/docs/plans/2026-03-08-access-control-design.md @@ -0,0 +1,120 @@ +# Access Control Design + +## Overview + +Site-wide access control for OCAP2-Web instances. Allows community operators to restrict who can view recordings via a single config mode. Builds on the existing role-based auth foundation (PR #311). + +## Mode System + +Single `auth.mode` config value. Default `public` (current behavior). + +| Mode | Description | +|------|-------------| +| `public` | No restrictions. Current behavior. | +| `password` | Shared viewer password. | +| `steam` | Any Steam account can view. | +| `steamGroup` | Steam login + Steam group membership required. | +| `squadXml` | Steam login + UID present in remote squad XML required. | + +All non-public modes issue a JWT with `viewer` role on successful authentication. + +## Gate Behavior + +### Protected Endpoints +- `/api/v1/operations*` — recording list, metadata, marker blacklist +- `/data/*` — recording data files + +### Always Public +- Static assets (`/static/*`) +- Map tiles (`/images/maps/*`) +- `/api/healthcheck` +- `/api/version` +- `/api/v1/customize` +- `/api/v1/auth/*` — login/callback/me endpoints +- `/api/v1/operations/add` — upload endpoint (has own `secret` auth) + +### Unauthenticated Flow +1. User hits protected endpoint → 401 +2. Frontend intercepts 401, saves current path to `sessionStorage` (`ocap_return_to`) +3. Redirect to login page +4. User authenticates via mode-appropriate method +5. JWT issued, redirect back to saved path + +## Per-Mode Auth Flow + +### `public` +No gate. Optional Steam login for admin access. + +### `password` +1. User enters shared password on login page +2. Backend validates password against `auth.password` config +3. JWT issued with `viewer` role + +### `steam` +1. User clicks Steam login button +2. Standard Steam OpenID flow +3. JWT issued with `viewer` role (or `admin` if in `adminSteamIds`) + +### `steamGroup` +1. User clicks Steam login button +2. Steam OpenID flow completes, Steam ID obtained +3. Backend checks group membership via Steam Web API (`steamApiKey` + `steamGroupId`) +4. **Member** → JWT issued with `viewer` role +5. **Not a member** → no token issued, error message, redirect to login + +### `squadXml` +1. User clicks Steam login button +2. Steam OpenID flow completes, Steam ID obtained +3. Backend fetches squad XML from `squadXmlUrl` (cached per `squadXmlCacheTTL`) +4. Checks if Steam UID is present in the XML +5. **Found** → JWT issued with `viewer` role +6. **Not found** → no token issued, error message, redirect to login + +## Admin Bypass + +Users whose Steam ID is in `adminSteamIds` always pass the gate regardless of mode. This prevents admin lockout (e.g. admin not in Steam group or squad XML). + +## Login UI + +| Mode | Primary Action | Secondary Action | +|------|---------------|-----------------| +| `public` | — | Steam button (admin) | +| `password` | Password field + submit | Steam button (admin) | +| `steam` | Steam button | — | +| `steamGroup` | Steam button | — | +| `squadXml` | Steam button | — | + +Visual lock icon or indicator when instance is restricted (non-public mode). + +## Configuration + +```json +"auth": { + "mode": "public", + "sessionTTL": "24h", + "adminSteamIds": ["76561198000074241"], + "steamApiKey": "...", + "password": "viewer-password-here", + "steamGroupId": "103582791460XXXXX", + "squadXmlUrl": "https://example.com/squad.xml", + "squadXmlCacheTTL": "5m" +} +``` + +Fields only relevant to the active mode are ignored. + +## Startup Validation + +Server validates on start that required config values for the active mode are present. Missing required values are fatal errors. Optional warnings for edge cases. + +| Mode | Required | Warnings | +|------|----------|----------| +| `public` | — | — | +| `password` | `password` | — | +| `steam` | — | — | +| `steamGroup` | `steamApiKey`, `steamGroupId` | — | +| `squadXml` | `steamApiKey`, `squadXmlUrl` | `squadXmlCacheTTL=0` → "caching disabled, fetching on every login" | + +## Future Compatibility + +Per-recording visibility (public/restricted/private per recording) is a separate layer that can be added later. Site-wide gate is middleware-level; per-recording is endpoint-level logic. No conflicts. From 91802f35f7db894d749a52098a1c05fd4dde9376 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:28:56 +0100 Subject: [PATCH 02/16] docs: access control implementation plan 9 tasks covering config, middleware, password login, Steam group check, squad XML check, API exposure, and frontend auth-gated UI. --- docs/plans/2026-03-08-access-control-impl.md | 1113 ++++++++++++++++++ 1 file changed, 1113 insertions(+) create mode 100644 docs/plans/2026-03-08-access-control-impl.md diff --git a/docs/plans/2026-03-08-access-control-impl.md b/docs/plans/2026-03-08-access-control-impl.md new file mode 100644 index 00000000..32799c6e --- /dev/null +++ b/docs/plans/2026-03-08-access-control-impl.md @@ -0,0 +1,1113 @@ +# Access Control Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add site-wide access control with five modes (public, password, steam, steamGroup, squadXml) to restrict who can view recordings. + +**Architecture:** New `requireViewer` middleware gates recording endpoints (`/api/v1/operations*`, `/data/*`). A new `auth.mode` config field controls which authentication method is required. Password mode adds a new backend endpoint; steamGroup/squadXml add membership checks in the Steam callback. The frontend `/api/v1/customize` response is extended with `authMode` so the UI can show the appropriate login controls. + +**Tech Stack:** Go (backend, fuego framework), SolidJS + TypeScript (frontend), Vitest (frontend tests), Go testing (backend tests) + +**Design doc:** `docs/plans/2026-03-08-access-control-design.md` + +--- + +### Task 1: Config — Add auth mode fields to Setting struct + +**Files:** +- Modify: `internal/server/setting.go:50-54` (Auth struct) +- Modify: `internal/server/setting.go:96-99` (viper defaults) +- Modify: `internal/server/setting.go:103` (env var bindings) +- Modify: `setting.json.example:35-39` (example config) + +**Step 1: Add fields to Auth struct** + +In `setting.go`, extend the `Auth` struct: + +```go +type Auth struct { + Mode string `json:"mode" yaml:"mode"` + SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` + AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` + SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` + Password string `json:"password" yaml:"password"` + SteamGroupID string `json:"steamGroupId" yaml:"steamGroupId"` + SquadXmlURL string `json:"squadXmlUrl" yaml:"squadXmlUrl"` + SquadXmlCacheTTL time.Duration `json:"squadXmlCacheTTL" yaml:"squadXmlCacheTTL"` +} +``` + +**Step 2: Add viper defaults** + +After line 99 (`viper.SetDefault("auth.steamApiKey", "")`), add: + +```go +viper.SetDefault("auth.mode", "public") +viper.SetDefault("auth.password", "") +viper.SetDefault("auth.steamGroupId", "") +viper.SetDefault("auth.squadXmlUrl", "") +viper.SetDefault("auth.squadXmlCacheTTL", "5m") +``` + +**Step 3: Add env var bindings** + +Add to the `envKeys` slice in line 103: + +``` +"auth.mode", "auth.password", "auth.steamGroupId", "auth.squadXmlUrl", "auth.squadXmlCacheTTL" +``` + +**Step 4: Add startup validation** + +Add a `validateAuthConfig` function and call it from `NewSetting()` after unmarshal (after line 124): + +```go +func validateAuthConfig(auth Auth) error { + validModes := []string{"public", "password", "steam", "steamGroup", "squadXml"} + if !slices.Contains(validModes, auth.Mode) { + return fmt.Errorf("auth.mode %q is not valid, must be one of: %s", auth.Mode, strings.Join(validModes, ", ")) + } + switch auth.Mode { + case "password": + if auth.Password == "" { + return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode) + } + case "steamGroup": + if auth.SteamAPIKey == "" { + return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) + } + if auth.SteamGroupID == "" { + return fmt.Errorf("auth.mode %q requires auth.steamGroupId to be set", auth.Mode) + } + case "squadXml": + if auth.SteamAPIKey == "" { + return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) + } + if auth.SquadXmlURL == "" { + return fmt.Errorf("auth.mode %q requires auth.squadXmlUrl to be set", auth.Mode) + } + if auth.SquadXmlCacheTTL == 0 { + log.Printf("WARN: auth.squadXmlCacheTTL is 0, squad XML will be fetched on every login") + } + } + return nil +} +``` + +Call it in `NewSetting()`: +```go +if err = validateAuthConfig(setting.Auth); err != nil { + return +} +``` + +**Step 5: Update setting.json.example** + +```json +"auth": { + "mode": "public", + "sessionTTL": "24h", + "adminSteamIds": [], + "steamApiKey": "", + "password": "", + "steamGroupId": "", + "squadXmlUrl": "", + "squadXmlCacheTTL": "5m" +} +``` + +**Step 6: Run tests** + +Run: `go test ./internal/server/ -run TestNew -v` + +**Step 7: Commit** + +``` +feat(auth): add access control mode config fields + +Adds mode, password, steamGroupId, squadXmlUrl, squadXmlCacheTTL +to auth config with startup validation. +``` + +--- + +### Task 2: Backend — requireViewer middleware + +**Files:** +- Modify: `internal/server/handler_auth.go` (add `requireViewer` middleware) +- Modify: `internal/server/handler.go:144-155` (apply middleware to recording/data routes) + +**Step 1: Write test for requireViewer** + +Add to `handler_auth_test.go`: + +```go +func TestRequireViewer(t *testing.T) { + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("public mode allows unauthenticated", func(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "public"}}, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/operations", nil) + hdlr.requireViewer(okHandler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("non-public mode rejects unauthenticated", func(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"}, + jwt: NewJWTManager("test-secret", time.Hour), + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/operations", nil) + hdlr.requireViewer(okHandler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("non-public mode allows viewer", func(t *testing.T) { + jwtMgr := NewJWTManager("test-secret", time.Hour) + token, _ := jwtMgr.Create("steam123", WithRole("viewer")) + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"}, + jwt: jwtMgr, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/operations", nil) + req.Header.Set("Authorization", "Bearer "+token) + hdlr.requireViewer(okHandler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("non-public mode allows admin", func(t *testing.T) { + jwtMgr := NewJWTManager("test-secret", time.Hour) + token, _ := jwtMgr.Create("steam123", WithRole("admin")) + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "steam"}, Secret: "test-secret"}, + jwt: jwtMgr, + } + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/operations", nil) + req.Header.Set("Authorization", "Bearer "+token) + hdlr.requireViewer(okHandler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestRequireViewer -v` +Expected: FAIL — `requireViewer` not defined + +**Step 3: Implement requireViewer** + +Add to `handler_auth.go` after the `requireAdmin` middleware: + +```go +// requireViewer is middleware that enforces site-wide access control. +// In "public" mode it passes all requests through. In all other modes +// it requires a valid JWT with any role (viewer or admin). +func (h *Handler) requireViewer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.setting.Auth.Mode == "public" { + next.ServeHTTP(w, r) + return + } + token := bearerToken(r) + if token == "" || h.jwt.Validate(token) != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/server/ -run TestRequireViewer -v` +Expected: PASS + +**Step 5: Apply middleware to routes** + +In `handler.go`, create a viewer-gated group for recording and data endpoints. Replace the current route registrations (lines 144-160) with: + +```go +// Viewer-gated routes (public in "public" mode, auth required in other modes) +viewer := fuego.Group(g, "") +fuego.Use(viewer, hdlr.requireViewer) + +// Recordings (viewer-gated) +fuego.Get(viewer, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings")) +fuego.Get(viewer, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings")) +fuego.Get(viewer, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings")) +fuego.Get(viewer, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings")) + +// Upload — stays on prefix group (has its own secret/JWT auth) +fuego.PostStd(g, "/api/v1/operations/add", hdlr.StoreOperation, fuego.OptionTags("Recordings")) + +// Customize — stays on prefix group (public, frontend needs it before auth) +fuego.Get(g, "/api/v1/customize", hdlr.GetCustomize, fuego.OptionTags("Recordings")) + +// Stream — stays on prefix group (has its own secret auth) +fuego.GetStd(g, "/api/v1/stream", hdlr.HandleStream, fuego.OptionTags("Recordings")) + +// Assets (viewer-gated for data, public for everything else) +cacheMiddleware := hdlr.cacheControl(CacheDuration) +fuego.GetStd(viewer, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +fuego.GetStd(g, "/images/markers/{name}/{color}", hdlr.GetMarker, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +fuego.GetStd(g, "/images/markers/magicons/{name}", hdlr.GetAmmo, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +fuego.GetStd(g, "/images/maps/fonts/{fontstack}/{range}", hdlr.GetFont, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +fuego.GetStd(g, "/images/maps/sprites/{name}", hdlr.GetSprite, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +fuego.GetStd(g, "/images/maps/{path...}", hdlr.GetMapTile, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) +``` + +**Step 6: Run all tests** + +Run: `go test ./internal/server/ -v` +Expected: PASS + +**Step 7: Commit** + +``` +feat(auth): add requireViewer middleware and gate recording/data endpoints + +In public mode all requests pass through. In other modes a valid JWT +is required to access recording list, metadata, and data files. +``` + +--- + +### Task 3: Backend — Password login endpoint + +**Files:** +- Modify: `internal/server/handler_auth.go` (add `PasswordLogin` handler) +- Modify: `internal/server/handler.go` (register route) + +**Step 1: Write test for password login** + +Add to `handler_auth_test.go`: + +```go +func TestPasswordLogin(t *testing.T) { + t.Run("correct password issues viewer JWT", func(t *testing.T) { + jwtMgr := NewJWTManager("test-secret", time.Hour) + hdlr := Handler{ + setting: Setting{ + Auth: Auth{Mode: "password", Password: "secret123"}, + Secret: "test-secret", + }, + jwt: jwtMgr, + } + + body := `{"password":"secret123"}` + req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + hdlr.PasswordLogin(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]string + json.NewDecoder(rr.Body).Decode(&resp) + assert.NotEmpty(t, resp["token"]) + + claims := jwtMgr.Claims(resp["token"]) + assert.Equal(t, "viewer", claims.Role) + assert.Equal(t, "password", claims.Subject) + }) + + t.Run("wrong password returns 401", func(t *testing.T) { + hdlr := Handler{ + setting: Setting{ + Auth: Auth{Mode: "password", Password: "secret123"}, + Secret: "test-secret", + }, + jwt: NewJWTManager("test-secret", time.Hour), + } + + body := `{"password":"wrong"}` + req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + hdlr.PasswordLogin(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("empty password returns 401", func(t *testing.T) { + hdlr := Handler{ + setting: Setting{ + Auth: Auth{Mode: "password", Password: "secret123"}, + Secret: "test-secret", + }, + jwt: NewJWTManager("test-secret", time.Hour), + } + + body := `{"password":""}` + req := httptest.NewRequest("POST", "/api/v1/auth/password", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + hdlr.PasswordLogin(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestPasswordLogin -v` + +**Step 3: Implement PasswordLogin** + +Add to `handler_auth.go`: + +```go +// PasswordLogin validates a shared password and issues a viewer JWT. +func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if req.Password == "" || req.Password != h.setting.Auth.Password { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + + token, err := h.jwt.Create("password", WithRole("viewer")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} +``` + +**Step 4: Register route** + +In `handler.go`, add in the Auth section (after line 166): + +```go +fuego.PostStd(g, "/api/v1/auth/password", hdlr.PasswordLogin, fuego.OptionTags("Auth")) +``` + +**Step 5: Run tests** + +Run: `go test ./internal/server/ -run TestPasswordLogin -v` +Expected: PASS + +**Step 6: Commit** + +``` +feat(auth): add password login endpoint + +POST /api/v1/auth/password accepts {"password":"..."} and issues a +viewer JWT when the password matches auth.password config. +``` + +--- + +### Task 4: Backend — Steam group membership check + +**Files:** +- Modify: `internal/server/handler_auth.go` (add group check in SteamCallback) + +**Step 1: Write test for Steam group membership check** + +Add to `handler_auth_test.go`: + +```go +func TestSteamGroupMembershipCheck(t *testing.T) { + t.Run("member gets viewer token", func(t *testing.T) { + // Mock Steam group members API + groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "success": 1, + "members": []map[string]string{ + {"steamid": "76561198012345678"}, + {"steamid": "76561198099999999"}, + }, + }, + }) + })) + defer groupServer.Close() + + result, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000") + assert.NoError(t, err) + assert.True(t, result) + }) + + t.Run("non-member is rejected", func(t *testing.T) { + groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "success": 1, + "members": []map[string]string{ + {"steamid": "76561198099999999"}, + }, + }, + }) + })) + defer groupServer.Close() + + result, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000") + assert.NoError(t, err) + assert.False(t, result) + }) + + t.Run("API error returns error", func(t *testing.T) { + groupServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer groupServer.Close() + + _, err := checkSteamGroupMembership(groupServer.URL, "76561198012345678", "test-api-key", "103582791460000000") + assert.Error(t, err) + }) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestSteamGroupMembership -v` + +**Step 3: Implement checkSteamGroupMembership** + +Add to `handler_auth.go`: + +```go +// steamGroupMembersResponse models the Steam Web API GetGroupMembers response. +type steamGroupMembersResponse struct { + Response struct { + Success int `json:"success"` + Members []struct { + SteamID string `json:"steamid"` + } `json:"members"` + } `json:"response"` +} + +// checkSteamGroupMembership checks if a Steam ID is a member of a Steam group +// using the Steam Web API (ISteamUser/GetUserGroupList is per-user; we use +// ISteamUser/GetGroupMembers which is the group-level API). +func checkSteamGroupMembership(baseURL, steamID, apiKey, groupID string) (bool, error) { + u := baseURL + "?key=" + url.QueryEscape(apiKey) + "&groupid=" + url.QueryEscape(groupID) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(u) + if err != nil { + return false, fmt.Errorf("steam group API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("steam group API error: status %d", resp.StatusCode) + } + + var data steamGroupMembersResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return false, fmt.Errorf("steam group API decode error: %w", err) + } + + for _, m := range data.Response.Members { + if m.SteamID == steamID { + return true, nil + } + } + return false, nil +} +``` + +**Step 4: Integrate into SteamCallback** + +In `SteamCallback` (handler_auth.go), after the role determination (after line 117), add the membership check before issuing the token: + +```go + // In steamGroup mode, check group membership (admins bypass) + if h.setting.Auth.Mode == "steamGroup" && role != "admin" { + baseURL := steamGroupAPIBaseURL + if h.steamAPIBaseURL != "" { + baseURL = h.steamAPIBaseURL + "/GetGroupMembers" + } + isMember, err := checkSteamGroupMembership(baseURL, steamID, h.setting.Auth.SteamAPIKey, h.setting.Auth.SteamGroupID) + if err != nil { + log.Printf("WARN: steam group membership check failed for %s: %v", steamID, err) + h.authRedirect(w, r, "auth_error=membership_check_failed") + return + } + if !isMember { + h.authRedirect(w, r, "auth_error=not_a_member") + return + } + } +``` + +Add the constant: +```go +const steamGroupAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/" +``` + +Note: The actual Steam Web API endpoint for checking group membership may need adjustment based on Steam's API. The `GetUserGroupList` endpoint returns groups a user belongs to (keyed by user), which may be more practical than fetching all group members. Verify the correct endpoint during implementation. + +**Step 5: Run tests** + +Run: `go test ./internal/server/ -run TestSteamGroup -v` +Expected: PASS + +**Step 6: Commit** + +``` +feat(auth): add Steam group membership check + +In steamGroup mode, non-admin users must be a member of the configured +Steam group. Admins bypass the check to prevent lockout. +``` + +--- + +### Task 5: Backend — Squad XML membership check + +**Files:** +- Modify: `internal/server/handler_auth.go` (add squad XML fetcher + cache + check in SteamCallback) + +**Step 1: Write test for squad XML parsing and membership check** + +Add to `handler_auth_test.go`: + +```go +func TestSquadXmlMembershipCheck(t *testing.T) { + squadXml := ` + + + Test Group + + +` + + t.Run("member found in squad XML", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(squadXml)) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 0) + result, err := checker.isMember("76561198012345678") + assert.NoError(t, err) + assert.True(t, result) + }) + + t.Run("non-member not found in squad XML", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(squadXml)) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 0) + result, err := checker.isMember("76561198000000000") + assert.NoError(t, err) + assert.False(t, result) + }) + + t.Run("caching avoids refetch", func(t *testing.T) { + fetchCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write([]byte(squadXml)) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + checker.isMember("76561198012345678") + checker.isMember("76561198012345678") + assert.Equal(t, 1, fetchCount) + }) + + t.Run("zero TTL refetches every time", func(t *testing.T) { + fetchCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount++ + w.Write([]byte(squadXml)) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 0) + checker.isMember("76561198012345678") + checker.isMember("76561198012345678") + assert.Equal(t, 2, fetchCount) + }) + + t.Run("HTTP error returns error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 0) + _, err := checker.isMember("76561198012345678") + assert.Error(t, err) + }) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestSquadXml -v` + +**Step 3: Implement squadXmlChecker** + +Add to `handler_auth.go`: + +```go +// squadXmlChecker fetches and caches a remote Arma 3 squad XML, +// then checks membership by Steam ID. +type squadXmlChecker struct { + url string + cacheTTL time.Duration + + mu sync.Mutex + members map[string]bool + fetchedAt time.Time +} + +func newSquadXmlChecker(url string, cacheTTL time.Duration) *squadXmlChecker { + return &squadXmlChecker{ + url: url, + cacheTTL: cacheTTL, + } +} + +// squadXml models the Arma 3 squad.xml format. +type squadXml struct { + Members []squadXmlMember `xml:"member"` +} + +type squadXmlMember struct { + ID string `xml:"id,attr"` +} + +func (c *squadXmlChecker) isMember(steamID string) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.members != nil && c.cacheTTL > 0 && time.Since(c.fetchedAt) < c.cacheTTL { + return c.members[steamID], nil + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(c.url) + if err != nil { + return false, fmt.Errorf("squad XML fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("squad XML fetch error: status %d", resp.StatusCode) + } + + var squad squadXml + if err := xml.NewDecoder(resp.Body).Decode(&squad); err != nil { + return false, fmt.Errorf("squad XML parse error: %w", err) + } + + c.members = make(map[string]bool, len(squad.Members)) + for _, m := range squad.Members { + c.members[m.ID] = true + } + c.fetchedAt = time.Now() + + return c.members[steamID], nil +} +``` + +Add `"encoding/xml"` and `"sync"` to the imports. + +**Step 4: Add squadXmlChecker to Handler and integrate into SteamCallback** + +Add field to Handler struct in `handler.go`: +```go +squadXml *squadXmlChecker +``` + +Initialize in `NewHandler` (after JWT setup, around line 133): +```go +if hdlr.setting.Auth.Mode == "squadXml" { + hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL) +} +``` + +Add check in `SteamCallback` (after the steamGroup check): +```go + // In squadXml mode, check squad XML membership (admins bypass) + if h.setting.Auth.Mode == "squadXml" && role != "admin" { + isMember, err := h.squadXml.isMember(steamID) + if err != nil { + log.Printf("WARN: squad XML membership check failed for %s: %v", steamID, err) + h.authRedirect(w, r, "auth_error=membership_check_failed") + return + } + if !isMember { + h.authRedirect(w, r, "auth_error=not_a_member") + return + } + } +``` + +**Step 5: Run tests** + +Run: `go test ./internal/server/ -run TestSquadXml -v` +Expected: PASS + +**Step 6: Commit** + +``` +feat(auth): add squad XML membership check with caching + +In squadXml mode, fetches the remote squad.xml and checks if the +user's Steam ID is listed. Cache TTL is configurable; 0 disables. +``` + +--- + +### Task 6: Backend — Expose auth mode via /api/v1/customize + +**Files:** +- Modify: `internal/server/handler.go` (extend GetCustomize response) + +**Step 1: Write test** + +Add to `handler_test.go`: + +```go +func TestGetCustomize_IncludesAuthMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{ + Auth: Auth{Mode: "steamGroup"}, + Customize: Customize{Enabled: true}, + }, + } + mockCtx := newMockContext("GET", "/api/v1/customize") + result, err := hdlr.GetCustomize(mockCtx) + assert.NoError(t, err) + assert.Equal(t, "steamGroup", result.AuthMode) +} + +func TestGetCustomize_PublicModeDefault(t *testing.T) { + hdlr := Handler{ + setting: Setting{ + Auth: Auth{Mode: "public"}, + Customize: Customize{Enabled: true}, + }, + } + mockCtx := newMockContext("GET", "/api/v1/customize") + result, err := hdlr.GetCustomize(mockCtx) + assert.NoError(t, err) + assert.Equal(t, "public", result.AuthMode) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/ -run TestGetCustomize_IncludesAuthMode -v` + +**Step 3: Implement** + +The current `GetCustomize` returns `*Customize` directly. We need a response wrapper that includes auth mode. Change the response type: + +```go +type CustomizeResponse struct { + Customize + AuthMode string `json:"authMode"` +} + +func (h *Handler) GetCustomize(c ContextNoBody) (*CustomizeResponse, error) { + resp := &CustomizeResponse{ + AuthMode: h.setting.Auth.Mode, + } + if h.setting.Customize.Enabled { + resp.Customize = h.setting.Customize + } else { + c.SetStatus(http.StatusNoContent) + return nil, nil + } + return resp, nil +} +``` + +Wait — the current behavior returns 204 No Content when customize is disabled. But we always need the auth mode. Rethink: always return a response, but only populate customize fields when enabled: + +```go +type CustomizeResponse struct { + *Customize `json:"customize,omitempty"` + AuthMode string `json:"authMode"` +} + +func (h *Handler) GetCustomize(c ContextNoBody) (CustomizeResponse, error) { + resp := CustomizeResponse{ + AuthMode: h.setting.Auth.Mode, + } + if h.setting.Customize.Enabled { + resp.Customize = &h.setting.Customize + } + return resp, nil +} +``` + +This is a **breaking change** — the endpoint previously returned the `Customize` struct directly (or 204). Now it wraps it. The frontend `useCustomize.tsx` and `apiClient.ts` will need updating in the frontend task. The fuego route type signature also needs updating. + +Note: Evaluate whether it's cleaner to add a separate `/api/v1/auth/config` endpoint that returns just `{"mode":"steamGroup"}` instead of modifying `/api/v1/customize`. This avoids the breaking change. **Decision to make during implementation** — either approach works, but a separate endpoint is lower risk. + +**Step 4: Run tests and fix any broken customize tests** + +Run: `go test ./internal/server/ -run TestGetCustomize -v` +Fix any tests that relied on the old response shape. + +**Step 5: Commit** + +``` +feat(auth): expose auth mode to frontend + +Adds authMode field to the customize response (or a new /api/v1/auth/config +endpoint) so the frontend knows which login controls to show. +``` + +--- + +### Task 7: Frontend — Add auth mode to API client and useCustomize + +**Files:** +- Modify: `ui/src/data/apiClient.ts` (add password login method, update customize types) +- Modify: `ui/src/hooks/useCustomize.tsx` (expose auth mode) + +**Step 1: Update API client** + +In `apiClient.ts`, add the password login method: + +```typescript +async passwordLogin(password: string): Promise<{ token: string }> { + const resp = await fetch(`${this.base}/api/v1/auth/password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!resp.ok) { + throw new Error(resp.status === 401 ? "Invalid password" : "Login failed"); + } + return resp.json(); +} +``` + +Update the `CustomizeConfig` type to include `authMode`: + +```typescript +export interface CustomizeConfig { + // ... existing fields ... + authMode?: string; +} +``` + +Or if using a separate endpoint: +```typescript +export interface AuthConfig { + mode: string; +} + +async getAuthConfig(): Promise { + const resp = await fetch(`${this.base}/api/v1/auth/config`); + return resp.json(); +} +``` + +**Step 2: Add auth error messages** + +In `useAuth.tsx`, add new error message mappings: + +```typescript +const AUTH_ERROR_MESSAGES: Record = { + steam_error: "Steam login failed. Please try again.", + not_a_member: "You are not a member of this community. Contact an admin for access.", + membership_check_failed: "Could not verify membership. Please try again later.", +}; +``` + +**Step 3: Commit** + +``` +feat(auth): add password login and auth mode to frontend API client +``` + +--- + +### Task 8: Frontend — Auth-gated UI with login page + +**Files:** +- Modify: `ui/src/hooks/useAuth.tsx` (add password login, auth mode awareness) +- Modify: `ui/src/components/AuthBadge.tsx` (show password field in password mode) +- Modify: `ui/src/App.tsx` or `ui/src/main.tsx` (intercept 401 and show login) + +**Step 1: Add auth mode to AuthProvider** + +In `useAuth.tsx`, fetch auth mode on mount and expose it: + +```typescript +const [authMode, setAuthMode] = createSignal("public"); + +onMount(async () => { + // Fetch auth config first + try { + const config = await api.getAuthConfig(); + setAuthMode(config.mode); + } catch { + // Default to public if endpoint fails + } + // ... existing token consumption logic ... +}); +``` + +Add `authMode` and `loginWithPassword` to the context: + +```typescript +loginWithPassword: async (password: string) => { + try { + const resp = await api.passwordLogin(password); + setAuthToken(resp.token); + const me = await api.getMe(); + if (me.authenticated) { + setAuthenticated(true); + setRole(me.role ?? null); + // ... set other fields ... + } + } catch (err) { + setAuthError(err instanceof Error ? err.message : "Login failed"); + } +}, +``` + +**Step 2: Update AuthBadge for password mode** + +In `AuthBadge.tsx`, when not authenticated and `authMode() === "password"`: + +```tsx + + +
+ setPassword(e.currentTarget.value)} + /> + +
+
+ + + +
+``` + +In password mode: show password field as primary + Steam button as secondary. +In steam/steamGroup/squadXml modes: show only Steam button. +In public mode: show only Steam button (for admin access). + +**Step 3: Handle 401 redirect for direct links** + +In `apiClient.ts`, add a global 401 handler for recording endpoints. When a fetch to `/api/v1/operations` or `/data/` returns 401: + +```typescript +if (resp.status === 401) { + sessionStorage.setItem("ocap_return_to", window.location.pathname); + window.location.href = basePath + "/"; + throw new Error("Authentication required"); +} +``` + +This triggers the existing `ocap_return_to` → login → redirect-back flow. + +**Step 4: Write tests** + +Add tests to `AuthBadge.test.tsx`: +- Password mode shows password field +- Password mode shows Steam button as secondary +- Steam mode shows only Steam button +- Public mode shows only Steam button + +Add tests to `useAuth.test.tsx`: +- `loginWithPassword` success flow +- `loginWithPassword` wrong password shows error +- Auth mode is exposed from provider + +**Step 5: Run tests** + +Run: `cd ui && npm test` +Expected: PASS + +**Step 6: Commit** + +``` +feat(auth): add auth-gated login UI with password and Steam modes + +Shows appropriate login controls based on auth.mode config. +Handles 401 redirect for direct recording links. +``` + +--- + +### Task 9: Integration testing and cleanup + +**Step 1: Manual integration test matrix** + +Test each mode with the dev server: + +| Mode | Test | +|------|------| +| `public` | All recordings accessible without login | +| `password` | Recordings blocked → enter password → access granted | +| `steam` | Recordings blocked → Steam login → access granted | +| `steamGroup` | Steam login → member gets access, non-member gets error | +| `squadXml` | Steam login → member gets access, non-member gets error | + +For each mode also test: +- Direct link redirect flow (copy recording URL, open in incognito, verify redirect to login then back) +- Admin bypass (admin can access in all modes) +- Upload endpoint still works with secret (not gated) + +**Step 2: Run full test suite** + +```bash +go test ./... +cd ui && npm test +``` + +**Step 3: Final commit if any cleanup needed** + +``` +chore: clean up access control implementation +``` + +--- + +## Notes for implementer + +- **Steam Group API**: Verify which Steam Web API endpoint is correct for group membership. Options: + - `ISteamUser/GetUserGroupList/v1/?key=X&steamid=Y` — returns groups a user belongs to (simpler, no pagination) + - Custom group members endpoint — may require pagination for large groups + - The user-centric approach (check if user is in group) is likely better than fetching all group members +- **Squad XML format**: Standard Arma 3 format with `` elements +- **Error codes**: `auth_error=not_a_member` and `auth_error=membership_check_failed` are new values the frontend needs to handle +- **Breaking change**: If `/api/v1/customize` response shape changes, existing frontend code needs updating. Consider a separate `/api/v1/auth/config` endpoint to avoid this. +- **Thread safety**: `squadXmlChecker` uses a mutex for cache access since multiple requests may hit it concurrently. From 477ab726dec907c3993ddd1beb7b9ae3fee54aea Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:39:46 +0100 Subject: [PATCH 03/16] feat: add auth mode fields and startup validation to Setting Add Mode, Password, SteamGroupID, SquadXmlURL, SquadXmlCacheTTL to the Auth struct with viper defaults, env var bindings, and a validateAuthConfig function that checks required fields per mode (public, password, steam, steamGroup, squadXml). --- internal/server/setting.go | 56 ++++++++++++++-- internal/server/setting_test.go | 114 ++++++++++++++++++++++++++++++++ setting.json.example | 7 +- 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/internal/server/setting.go b/internal/server/setting.go index 6d682c5c..8d62a7b6 100644 --- a/internal/server/setting.go +++ b/internal/server/setting.go @@ -3,7 +3,9 @@ package server import ( "encoding/json" "fmt" + "log" "os" + "slices" "strings" "time" @@ -48,9 +50,14 @@ type Customize struct { } type Auth struct { - SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` - AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` - SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` + Mode string `json:"mode" yaml:"mode"` + SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` + AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` + SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` + Password string `json:"password" yaml:"password"` + SteamGroupID string `json:"steamGroupId" yaml:"steamGroupId"` + SquadXmlURL string `json:"squadXmlUrl" yaml:"squadXmlUrl"` + SquadXmlCacheTTL time.Duration `json:"squadXmlCacheTTL" yaml:"squadXmlCacheTTL"` } type Streaming struct { @@ -97,10 +104,14 @@ func NewSetting() (setting Setting, err error) { viper.SetDefault("auth.sessionTTL", "24h") viper.SetDefault("auth.adminSteamIds", []string{}) viper.SetDefault("auth.steamApiKey", "") - + viper.SetDefault("auth.mode", "public") + viper.SetDefault("auth.password", "") + viper.SetDefault("auth.steamGroupId", "") + viper.SetDefault("auth.squadXmlUrl", "") + viper.SetDefault("auth.squadXmlCacheTTL", "5m") // workaround for https://github.com/spf13/viper/issues/761 - envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey"} + envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey", "auth.mode", "auth.password", "auth.steamGroupId", "auth.squadXmlUrl", "auth.squadXmlCacheTTL"} for _, key := range envKeys { env := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) if err = viper.BindEnv(key, env); err != nil { @@ -123,6 +134,10 @@ func NewSetting() (setting Setting, err error) { // so a value like "id1,id2" ends up as ["id1,id2"]. Expand it. setting.Auth.AdminSteamIDs = splitCSV(setting.Auth.AdminSteamIDs) + if err = validateAuthConfig(setting.Auth); err != nil { + return + } + // Viper can't unmarshal a JSON string env var into map[string]string, // so parse OCAP_CUSTOMIZE_CSSOVERRIDES manually if set. Env var takes // precedence over config file. @@ -145,6 +160,37 @@ func NewSetting() (setting Setting, err error) { return } +func validateAuthConfig(auth Auth) error { + validModes := []string{"public", "password", "steam", "steamGroup", "squadXml"} + if !slices.Contains(validModes, auth.Mode) { + return fmt.Errorf("auth.mode %q is not valid, must be one of: %s", auth.Mode, strings.Join(validModes, ", ")) + } + switch auth.Mode { + case "password": + if auth.Password == "" { + return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode) + } + case "steamGroup": + if auth.SteamAPIKey == "" { + return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) + } + if auth.SteamGroupID == "" { + return fmt.Errorf("auth.mode %q requires auth.steamGroupId to be set", auth.Mode) + } + case "squadXml": + if auth.SteamAPIKey == "" { + return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) + } + if auth.SquadXmlURL == "" { + return fmt.Errorf("auth.mode %q requires auth.squadXmlUrl to be set", auth.Mode) + } + if auth.SquadXmlCacheTTL == 0 { + log.Printf("WARN: auth.squadXmlCacheTTL is 0, squad XML will be fetched on every login") + } + } + return nil +} + // splitCSV expands a []string where one element may contain comma-separated // values (from an env var) into individual trimmed entries. func splitCSV(in []string) []string { diff --git a/internal/server/setting_test.go b/internal/server/setting_test.go index 1740dda7..0f587ee8 100644 --- a/internal/server/setting_test.go +++ b/internal/server/setting_test.go @@ -490,3 +490,117 @@ func TestNewSetting_NoConfigFile(t *testing.T) { _, err := NewSetting() assert.Error(t, err) } + +func TestValidateAuthConfig(t *testing.T) { + t.Run("valid modes accepted", func(t *testing.T) { + for _, mode := range []string{"public", "steam"} { + err := validateAuthConfig(Auth{Mode: mode}) + assert.NoError(t, err, "mode %q should be valid", mode) + } + err := validateAuthConfig(Auth{Mode: "password", Password: "secret"}) + assert.NoError(t, err) + err = validateAuthConfig(Auth{Mode: "steamGroup", SteamAPIKey: "key", SteamGroupID: "123"}) + assert.NoError(t, err) + err = validateAuthConfig(Auth{Mode: "squadXml", SteamAPIKey: "key", SquadXmlURL: "https://example.com/squad.xml", SquadXmlCacheTTL: 5 * time.Minute}) + assert.NoError(t, err) + }) + + t.Run("invalid mode returns error", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "bogus"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "bogus") + assert.Contains(t, err.Error(), "not valid") + }) + + t.Run("password mode without password", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "password"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "auth.password") + }) + + t.Run("steamGroup mode without steamApiKey", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "steamGroup", SteamGroupID: "123"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "auth.steamApiKey") + }) + + t.Run("steamGroup mode without steamGroupId", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "steamGroup", SteamAPIKey: "key"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "auth.steamGroupId") + }) + + t.Run("squadXml mode without steamApiKey", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "squadXml", SquadXmlURL: "https://example.com/squad.xml"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "auth.steamApiKey") + }) + + t.Run("squadXml mode without squadXmlUrl", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "squadXml", SteamAPIKey: "key"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "auth.squadXmlUrl") + }) + + t.Run("squadXml mode with zero cacheTTL does not error", func(t *testing.T) { + err := validateAuthConfig(Auth{ + Mode: "squadXml", + SteamAPIKey: "key", + SquadXmlURL: "https://example.com/squad.xml", + SquadXmlCacheTTL: 0, + }) + assert.NoError(t, err) + }) +} + +func TestNewSetting_AuthModeDefault(t *testing.T) { + defer viper.Reset() + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{"secret": "test-secret-value"}`), 0644) + require.NoError(t, err) + + viper.Reset() + viper.AddConfigPath(dir) + setting, err := NewSetting() + require.NoError(t, err) + + assert.Equal(t, "public", setting.Auth.Mode) + assert.Equal(t, 5*time.Minute, setting.Auth.SquadXmlCacheTTL) +} + +func TestNewSetting_AuthModeInvalid(t *testing.T) { + defer viper.Reset() + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{ + "secret": "test-secret-value", + "auth": {"mode": "invalid"} + }`), 0644) + require.NoError(t, err) + + viper.Reset() + viper.AddConfigPath(dir) + _, err = NewSetting() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid") +} + +func TestNewSetting_AuthPasswordMode(t *testing.T) { + defer viper.Reset() + + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "setting.json"), []byte(`{ + "secret": "test-secret-value", + "auth": {"mode": "password", "password": "hunter2"} + }`), 0644) + require.NoError(t, err) + + viper.Reset() + viper.AddConfigPath(dir) + setting, err := NewSetting() + require.NoError(t, err) + + assert.Equal(t, "password", setting.Auth.Mode) + assert.Equal(t, "hunter2", setting.Auth.Password) +} diff --git a/setting.json.example b/setting.json.example index 708b74ba..fe1359e5 100644 --- a/setting.json.example +++ b/setting.json.example @@ -33,8 +33,13 @@ "pingTimeout": "10s" }, "auth": { + "mode": "public", "sessionTTL": "24h", "adminSteamIds": [], - "steamApiKey": "" + "steamApiKey": "", + "password": "", + "steamGroupId": "", + "squadXmlUrl": "", + "squadXmlCacheTTL": "5m" } } From df96d87e4b26d6c68abe2779f79c256e6c41b602 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:45:08 +0100 Subject: [PATCH 04/16] feat: add requireViewer middleware and apply to recording/data routes Add requireViewer middleware that enforces site-wide access control: in "public" mode all requests pass through, in other modes a valid JWT is required. Apply it to recording list/detail, marker-blacklist, worlds, and data endpoints via a viewer-gated route group. --- internal/server/handler.go | 16 ++++--- internal/server/handler_auth.go | 18 ++++++++ internal/server/handler_auth_test.go | 66 ++++++++++++++++++++++++++++ internal/server/handler_test.go | 1 + 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 6c535012..8cd6b19e 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -141,18 +141,22 @@ func NewHandler( fuego.Get(g, "/api/healthcheck", hdlr.GetHealthcheck, fuego.OptionTags("Health")) fuego.Get(g, "/api/version", hdlr.GetVersion, fuego.OptionTags("Health")) - // Recordings (public read) - fuego.Get(g, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings")) - fuego.Get(g, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings")) - fuego.Get(g, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings")) + // Public recording endpoints (own auth or needed before login) fuego.PostStd(g, "/api/v1/operations/add", hdlr.StoreOperation, fuego.OptionTags("Recordings")) - fuego.Get(g, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings")) fuego.Get(g, "/api/v1/customize", hdlr.GetCustomize, fuego.OptionTags("Recordings")) fuego.GetStd(g, "/api/v1/stream", hdlr.HandleStream, fuego.OptionTags("Recordings")) + // Viewer-gated endpoints (require valid JWT in non-public modes) + viewer := fuego.Group(g, "") + fuego.Use(viewer, hdlr.requireViewer) + fuego.Get(viewer, "/api/v1/operations", hdlr.GetOperations, fuego.OptionTags("Recordings")) + fuego.Get(viewer, "/api/v1/operations/{id}", hdlr.GetOperation, fuego.OptionTags("Recordings")) + fuego.Get(viewer, "/api/v1/operations/{id}/marker-blacklist", hdlr.GetMarkerBlacklist, fuego.OptionTags("Recordings")) + fuego.Get(viewer, "/api/v1/worlds", hdlr.GetWorlds, fuego.OptionTags("Recordings")) + // Assets (static file serving) cacheMiddleware := hdlr.cacheControl(CacheDuration) - fuego.GetStd(g, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) + fuego.GetStd(viewer, "/data/{path...}", hdlr.GetData, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) fuego.GetStd(g, "/images/markers/{name}/{color}", hdlr.GetMarker, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) fuego.GetStd(g, "/images/markers/magicons/{name}", hdlr.GetAmmo, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) fuego.GetStd(g, "/images/maps/fonts/{fontstack}/{range}", hdlr.GetFont, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 93221b23..4603001e 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -185,6 +185,24 @@ func (h *Handler) Logout(c ContextNoBody) (any, error) { return nil, nil } +// requireViewer is middleware that enforces site-wide access control. +// In "public" mode it passes all requests through. In all other modes +// it requires a valid JWT with any role (viewer or admin). +func (h *Handler) requireViewer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if h.setting.Auth.Mode == "public" { + next.ServeHTTP(w, r) + return + } + token := bearerToken(r) + if token == "" || h.jwt.Validate(token) != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + // requireAdmin is middleware that checks for a valid JWT Bearer token with admin role. func (h *Handler) requireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 4a7eaef1..8ee8f3f6 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -541,6 +541,72 @@ func TestRequireAdmin_AllowsAdminRole(t *testing.T) { assert.True(t, called) } +func TestRequireViewer_PublicMode_AllowsUnauthenticated(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + hdlr.setting.Auth.Mode = "public" + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + hdlr.requireViewer(next).ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} + +func TestRequireViewer_NonPublic_RejectsUnauthenticated(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + hdlr.setting.Auth.Mode = "steam" + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + hdlr.requireViewer(next).ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestRequireViewer_NonPublic_AllowsViewerRole(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + hdlr.setting.Auth.Mode = "steam" + token, err := hdlr.jwt.Create("76561198012345678", WithRole("viewer")) + require.NoError(t, err) + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + hdlr.requireViewer(next).ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} + +func TestRequireViewer_NonPublic_AllowsAdminRole(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + hdlr.setting.Auth.Mode = "steam" + token, err := hdlr.jwt.Create("76561198012345678", WithRole("admin")) + require.NoError(t, err) + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + hdlr.requireViewer(next).ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} + func TestGetMe_ReturnsRole(t *testing.T) { hdlr := newSteamAuthHandler(nil) token, err := hdlr.jwt.Create("76561198012345678", WithRole("viewer")) diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index e7e4a0a5..8f60e86e 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -831,6 +831,7 @@ func TestNewHandler(t *testing.T) { Data: dataDir, Markers: markerDir, Ammo: ammoDir, + Auth: Auth{Mode: "public"}, } s := fuego.NewServer(fuego.WithoutStartupMessages(), fuego.WithoutAutoGroupTags(), fuego.WithSecurity(OpenAPISecuritySchemes)) From ff3375527d5bfd27dbe0821a4b3f984ef65362a0 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:47:29 +0100 Subject: [PATCH 05/16] feat: add POST /api/v1/auth/password endpoint for shared-password login Accepts {"password":"..."}, validates against auth.password config, and issues a viewer JWT with "password" subject on success. --- internal/server/handler.go | 1 + internal/server/handler_auth.go | 25 +++++++++ internal/server/handler_auth_test.go | 82 ++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/internal/server/handler.go b/internal/server/handler.go index 8cd6b19e..51c43855 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -166,6 +166,7 @@ func NewHandler( // Auth fuego.GetStd(g, "/api/v1/auth/steam", hdlr.SteamLogin, fuego.OptionTags("Auth")) fuego.GetStd(g, "/api/v1/auth/steam/callback", hdlr.SteamCallback, fuego.OptionTags("Auth")) + fuego.PostStd(g, "/api/v1/auth/password", hdlr.PasswordLogin, fuego.OptionTags("Auth")) fuego.Get(g, "/api/v1/auth/me", hdlr.GetMe, fuego.OptionTags("Auth")) fuego.Post(g, "/api/v1/auth/logout", hdlr.Logout, fuego.OptionTags("Auth"), fuego.OptionSecurity(bearerAuth)) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 4603001e..bfca0245 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -154,6 +154,31 @@ func (h *Handler) authRedirect(w http.ResponseWriter, r *http.Request, query str http.Redirect(w, r, prefix, http.StatusTemporaryRedirect) } +// PasswordLogin validates a shared password and issues a viewer JWT. +func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if req.Password == "" || req.Password != h.setting.Auth.Password { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + + token, err := h.jwt.Create("password", WithRole("viewer")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + // MeResponse describes the authentication status returned by GetMe. type MeResponse struct { Authenticated bool `json:"authenticated"` diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 8ee8f3f6..838ce3c0 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -622,3 +622,85 @@ func TestGetMe_ReturnsRole(t *testing.T) { assert.True(t, resp.Authenticated) assert.Equal(t, "viewer", resp.Role) } + +func newPasswordAuthHandler(password string) Handler { + return Handler{ + setting: Setting{ + Secret: "test-secret", + Auth: Auth{ + SessionTTL: time.Hour, + Password: password, + }, + }, + jwt: NewJWTManager("test-secret", time.Hour), + } +} + +func TestPasswordLogin_CorrectPassword(t *testing.T) { + hdlr := newPasswordAuthHandler("s3cret") + + body := strings.NewReader(`{"password":"s3cret"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + + var resp map[string]string + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.NotEmpty(t, resp["token"]) + + claims := hdlr.jwt.Claims(resp["token"]) + require.NotNil(t, claims) + assert.Equal(t, "viewer", claims.Role) + assert.Equal(t, "password", claims.Subject) +} + +func TestPasswordLogin_WrongPassword(t *testing.T) { + hdlr := newPasswordAuthHandler("s3cret") + + body := strings.NewReader(`{"password":"wrong"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestPasswordLogin_EmptyPassword(t *testing.T) { + hdlr := newPasswordAuthHandler("s3cret") + + body := strings.NewReader(`{"password":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestPasswordLogin_InvalidJSON(t *testing.T) { + hdlr := newPasswordAuthHandler("s3cret") + + body := strings.NewReader(`not json`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPasswordLogin_MissingBody(t *testing.T) { + hdlr := newPasswordAuthHandler("s3cret") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusBadRequest, rec.Code) +} From c33b9019a1a7208fa59ea04626a9e3c86d5efea4 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:51:33 +0100 Subject: [PATCH 06/16] feat: add Steam group membership check in steamGroup auth mode After Steam OpenID login succeeds in steamGroup mode, verify the user belongs to the configured Steam group via ISteamUser/GetUserGroupList. Admins bypass the check. Non-members are redirected with auth_error=not_a_member; API failures redirect with auth_error=membership_check_failed. --- internal/server/handler_auth.go | 58 +++++ internal/server/handler_auth_test.go | 302 +++++++++++++++++++++++++++ 2 files changed, 360 insertions(+) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index bfca0245..dd9102e9 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -116,6 +116,24 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { role = "admin" } + // In steamGroup mode, check group membership (admins bypass) + if h.setting.Auth.Mode == "steamGroup" && role != "admin" { + baseURL := steamGroupAPIBaseURL + if h.steamAPIBaseURL != "" { + baseURL = h.steamAPIBaseURL + } + isMember, err := checkSteamGroupMembership(baseURL, steamID, h.setting.Auth.SteamAPIKey, h.setting.Auth.SteamGroupID) + if err != nil { + log.Printf("WARN: steam group membership check failed for %s: %v", steamID, err) + h.authRedirect(w, r, "auth_error=membership_check_failed") + return + } + if !isMember { + h.authRedirect(w, r, "auth_error=not_a_member") + return + } + } + // Fetch Steam profile data if API key is configured claimOpts := []ClaimOption{WithRole(role)} if h.setting.Auth.SteamAPIKey != "" { @@ -282,6 +300,7 @@ type steamProfileResponse struct { } const steamAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/" +const steamGroupAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/" // fetchSteamProfileFrom calls the Steam Web API to get the player's display name and avatar. func fetchSteamProfileFrom(baseURL, steamID, apiKey string) (name, avatar string, err error) { @@ -311,6 +330,45 @@ func fetchSteamProfileFrom(baseURL, steamID, apiKey string) (name, avatar string return p.PersonaName, p.AvatarURL, nil } +// steamGroupListResponse models the Steam Web API GetUserGroupList response. +type steamGroupListResponse struct { + Response struct { + Success bool `json:"success"` + Groups []struct { + GID string `json:"gid"` + } `json:"groups"` + } `json:"response"` +} + +// checkSteamGroupMembership checks whether the given Steam user is a member +// of the specified Steam group by querying the Steam Web API. +func checkSteamGroupMembership(baseURL, steamID, apiKey, groupID string) (bool, error) { + u := baseURL + "?key=" + url.QueryEscape(apiKey) + "&steamid=" + url.QueryEscape(steamID) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(u) + if err != nil { + return false, fmt.Errorf("steam group API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("steam group API error: status %d", resp.StatusCode) + } + + var data steamGroupListResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return false, fmt.Errorf("steam group API decode error: %w", err) + } + + for _, g := range data.Response.Groups { + if g.GID == groupID { + return true, nil + } + } + return false, nil +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 838ce3c0..bbd2ba01 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -704,3 +704,305 @@ func TestPasswordLogin_MissingBody(t *testing.T) { hdlr.PasswordLogin(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } + +// --- checkSteamGroupMembership unit tests --- + +func TestCheckSteamGroupMembership_IsMember(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "TESTKEY", r.URL.Query().Get("key")) + assert.Equal(t, "76561198012345678", r.URL.Query().Get("steamid")) + fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"},{"gid":"103582791460111111"}]}}`) + })) + defer srv.Close() + + isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") + require.NoError(t, err) + assert.True(t, isMember) +} + +func TestCheckSteamGroupMembership_NotAMember(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"}]}}`) + })) + defer srv.Close() + + isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460999999") + require.NoError(t, err) + assert.False(t, isMember) +} + +func TestCheckSteamGroupMembership_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + _, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "BADKEY", "103582791460111111") + assert.Error(t, err) + assert.Contains(t, err.Error(), "status 403") +} + +func TestCheckSteamGroupMembership_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `not json`) + })) + defer srv.Close() + + _, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") + assert.Error(t, err) + assert.Contains(t, err.Error(), "decode error") +} + +func TestCheckSteamGroupMembership_ConnectionError(t *testing.T) { + _, err := checkSteamGroupMembership("http://127.0.0.1:1/", "76561198012345678", "TESTKEY", "103582791460111111") + assert.Error(t, err) + assert.Contains(t, err.Error(), "request failed") +} + +func TestCheckSteamGroupMembership_EmptyGroups(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"response":{"success":true,"groups":[]}}`) + })) + defer srv.Close() + + isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") + require.NoError(t, err) + assert.False(t, isMember) +} + +// --- SteamCallback integration tests for steamGroup mode --- + +func newSteamGroupHandler(steamID string, adminIDs []string, groupID string) (Handler, *httptest.Server) { + // Create a mock server that handles both profile API and group membership API requests + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + // Route based on query parameters: group API uses "steamid" (singular), profile API uses "steamids" (plural) + if q.Get("steamid") != "" { + // GetUserGroupList request — return groupID as a member group + fmt.Fprintf(w, `{"response":{"success":true,"groups":[{"gid":"%s"}]}}`, groupID) + } else if q.Get("steamids") != "" { + // GetPlayerSummaries request + json.NewEncoder(w).Encode(steamProfileResponse{ + Response: struct { + Players []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + } `json:"players"` + }{ + Players: []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + }{ + {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, + }, + }, + }) + } + }) + srv := httptest.NewServer(mux) + + hdlr := Handler{ + setting: Setting{ + Secret: "test-secret", + Auth: Auth{ + Mode: "steamGroup", + SessionTTL: time.Hour, + AdminSteamIDs: adminIDs, + SteamAPIKey: "TESTKEY", + SteamGroupID: groupID, + }, + }, + jwt: NewJWTManager("test-secret", time.Hour), + openIDCache: openid.NewSimpleDiscoveryCache(), + openIDNonceStore: openid.NewSimpleNonceStore(), + openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/" + steamID}, + steamAPIBaseURL: srv.URL, + } + + return hdlr, srv +} + +func TestSteamCallback_SteamGroup_MemberGetsToken(t *testing.T) { + hdlr, srv := newSteamGroupHandler("76561198012345678", nil, "103582791460111111") + defer srv.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "viewer", claims.Role) +} + +func TestSteamCallback_SteamGroup_NonMemberGetsError(t *testing.T) { + // The mock server returns groupID "103582791460111111" as the user's group, + // but we configure the handler to require "999999999999999999" + hdlr, srv := newSteamGroupHandler("76561198012345678", nil, "999999999999999999") + defer srv.Close() + + // Override the mock to return a different group than what's required + srv.Close() + nonMemberSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("steamid") != "" { + // Return a group that doesn't match the required one + fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"}]}}`) + } else if q.Get("steamids") != "" { + json.NewEncoder(w).Encode(steamProfileResponse{ + Response: struct { + Players []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + } `json:"players"` + }{ + Players: []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + }{ + {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, + }, + }, + }) + } + })) + defer nonMemberSrv.Close() + hdlr.steamAPIBaseURL = nonMemberSrv.URL + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_error=not_a_member") + assert.NotContains(t, loc, "auth_token=") +} + +func TestSteamCallback_SteamGroup_AdminBypassesGroupCheck(t *testing.T) { + // Admin's steam ID is in the admin list; group check should be skipped entirely + // Use a group ID that doesn't match any group the user is in, to prove bypass + steamID := "76561198012345678" + hdlr, srv := newSteamGroupHandler(steamID, []string{steamID}, "999999999999999999") + defer srv.Close() + + // Override to return no matching group — if group check runs, it would fail + srv.Close() + noGroupSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("steamid") != "" { + // Return empty groups — would fail membership check + fmt.Fprint(w, `{"response":{"success":true,"groups":[]}}`) + } else if q.Get("steamids") != "" { + json.NewEncoder(w).Encode(steamProfileResponse{ + Response: struct { + Players []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + } `json:"players"` + }{ + Players: []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + }{ + {PersonaName: "AdminPlayer", AvatarURL: "https://example.com/admin.jpg"}, + }, + }, + }) + } + })) + defer noGroupSrv.Close() + hdlr.steamAPIBaseURL = noGroupSrv.URL + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "admin", claims.Role) +} + +func TestSteamCallback_SteamGroup_APIFailureRedirectsWithError(t *testing.T) { + steamID := "76561198012345678" + hdlr, srv := newSteamGroupHandler(steamID, nil, "103582791460111111") + defer srv.Close() + + // Replace with a server that returns 500 for group check + srv.Close() + failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("steamid") != "" { + w.WriteHeader(http.StatusInternalServerError) + } else if q.Get("steamids") != "" { + // Profile fetch still works + json.NewEncoder(w).Encode(steamProfileResponse{ + Response: struct { + Players []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + } `json:"players"` + }{ + Players: []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + }{ + {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, + }, + }, + }) + } + })) + defer failSrv.Close() + hdlr.steamAPIBaseURL = failSrv.URL + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_error=membership_check_failed") +} + +func TestSteamCallback_NonSteamGroupMode_SkipsGroupCheck(t *testing.T) { + // In "steam" mode (not "steamGroup"), group membership check should NOT run + hdlr := newSteamAuthHandler([]string{}) + hdlr.setting.Auth.Mode = "steam" + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") +} From efc33129fc93e1f9e1bc6717cea2fd258605dcf9 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:55:26 +0100 Subject: [PATCH 07/16] feat: add squad XML membership check for squadXml auth mode Implement squadXmlChecker that fetches a remote Arma 3 squad.xml, parses member Steam IDs, and caches the result with configurable TTL (0 = always refetch). Admins bypass the check. Integrated into the SteamCallback flow alongside the existing steamGroup check. --- internal/server/handler.go | 5 + internal/server/handler_auth.go | 72 ++++++++ internal/server/handler_auth_test.go | 256 +++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) diff --git a/internal/server/handler.go b/internal/server/handler.go index 51c43855..0a32fcc4 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -65,6 +65,7 @@ type Handler struct { openIDCache openid.DiscoveryCache openIDNonceStore openid.NonceStore steamAPIBaseURL string // override for testing; empty uses default + squadXml *squadXmlChecker spriteOnce sync.Once spriteFiles map[string][]byte @@ -132,6 +133,10 @@ func NewHandler( hdlr.openIDNonceStore = openid.NewSimpleNonceStore() hdlr.openIDVerifier = defaultOpenIDVerifier{} + if hdlr.setting.Auth.Mode == "squadXml" { + hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL) + } + prefixURL := strings.TrimRight(hdlr.setting.PrefixURL, "/") g := fuego.Group(s, prefixURL) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index dd9102e9..392ebe91 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -4,12 +4,14 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "encoding/xml" "fmt" "log" "net/http" "net/url" "slices" "strings" + "sync" "time" "github.com/yohcop/openid-go" @@ -134,6 +136,20 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { } } + // In squadXml mode, check squad XML membership (admins bypass) + if h.setting.Auth.Mode == "squadXml" && role != "admin" { + isMember, err := h.squadXml.isMember(steamID) + if err != nil { + log.Printf("WARN: squad XML membership check failed for %s: %v", steamID, err) + h.authRedirect(w, r, "auth_error=membership_check_failed") + return + } + if !isMember { + h.authRedirect(w, r, "auth_error=not_a_member") + return + } + } + // Fetch Steam profile data if API key is configured claimOpts := []ClaimOption{WithRole(role)} if h.setting.Auth.SteamAPIKey != "" { @@ -376,3 +392,59 @@ func randomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } + +// squadXmlChecker fetches and caches a remote Arma 3 squad XML, +// then checks membership by Steam ID. +type squadXmlChecker struct { + url string + cacheTTL time.Duration + + mu sync.Mutex + members map[string]bool + fetchedAt time.Time +} + +func newSquadXmlChecker(url string, cacheTTL time.Duration) *squadXmlChecker { + return &squadXmlChecker{url: url, cacheTTL: cacheTTL} +} + +type squadXml struct { + Members []squadXmlMember `xml:"member"` +} + +type squadXmlMember struct { + ID string `xml:"id,attr"` +} + +func (c *squadXmlChecker) isMember(steamID string) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.members != nil && c.cacheTTL > 0 && time.Since(c.fetchedAt) < c.cacheTTL { + return c.members[steamID], nil + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(c.url) + if err != nil { + return false, fmt.Errorf("squad XML fetch failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("squad XML fetch error: status %d", resp.StatusCode) + } + + var squad squadXml + if err := xml.NewDecoder(resp.Body).Decode(&squad); err != nil { + return false, fmt.Errorf("squad XML parse error: %w", err) + } + + c.members = make(map[string]bool, len(squad.Members)) + for _, m := range squad.Members { + c.members[m.ID] = true + } + c.fetchedAt = time.Now() + + return c.members[steamID], nil +} diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index bbd2ba01..4b4a57f6 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "strings" + "sync/atomic" "testing" "time" @@ -1006,3 +1007,258 @@ func TestSteamCallback_NonSteamGroupMode_SkipsGroupCheck(t *testing.T) { assert.Contains(t, loc, "auth_token=") assert.NotContains(t, loc, "auth_error") } + +// --- squadXmlChecker unit tests --- + +const testSquadXML = ` + + + Test Group + + +` + +func TestSquadXmlChecker_MemberFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, testSquadXML) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + isMember, err := checker.isMember("76561198012345678") + require.NoError(t, err) + assert.True(t, isMember) +} + +func TestSquadXmlChecker_NonMemberNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, testSquadXML) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + isMember, err := checker.isMember("76561198000000000") + require.NoError(t, err) + assert.False(t, isMember) +} + +func TestSquadXmlChecker_CachePreventsRefetch(t *testing.T) { + var fetchCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount.Add(1) + fmt.Fprint(w, testSquadXML) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + + // First call fetches + _, err := checker.isMember("76561198012345678") + require.NoError(t, err) + assert.Equal(t, int32(1), fetchCount.Load()) + + // Second call should use cache (same TTL, no expiry) + _, err = checker.isMember("76561198099999999") + require.NoError(t, err) + assert.Equal(t, int32(1), fetchCount.Load(), "should not refetch when cache is valid") +} + +func TestSquadXmlChecker_ZeroTTL_AlwaysRefetches(t *testing.T) { + var fetchCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fetchCount.Add(1) + fmt.Fprint(w, testSquadXML) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 0) // zero TTL = always refetch + + _, err := checker.isMember("76561198012345678") + require.NoError(t, err) + assert.Equal(t, int32(1), fetchCount.Load()) + + _, err = checker.isMember("76561198012345678") + require.NoError(t, err) + assert.Equal(t, int32(2), fetchCount.Load(), "should refetch every time with zero TTL") +} + +func TestSquadXmlChecker_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + _, err := checker.isMember("76561198012345678") + assert.Error(t, err) + assert.Contains(t, err.Error(), "status 500") +} + +func TestSquadXmlChecker_InvalidXML(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "not valid xml <><><<") + })) + defer srv.Close() + + checker := newSquadXmlChecker(srv.URL, 5*time.Minute) + _, err := checker.isMember("76561198012345678") + assert.Error(t, err) + assert.Contains(t, err.Error(), "parse error") +} + +func TestSquadXmlChecker_ConnectionError(t *testing.T) { + checker := newSquadXmlChecker("http://127.0.0.1:1/", 5*time.Minute) + _, err := checker.isMember("76561198012345678") + assert.Error(t, err) + assert.Contains(t, err.Error(), "fetch failed") +} + +// --- SteamCallback integration tests for squadXml mode --- + +func newSquadXmlHandler(steamID string, adminIDs []string, squadMembers []string) (Handler, *httptest.Server) { + // Build squad XML from member list + var members strings.Builder + for _, id := range squadMembers { + fmt.Fprintf(&members, ` `+"\n", id) + } + squadXMLBody := fmt.Sprintf(` + + Test Group +%s`, members.String()) + + // Create a mock server that handles squad XML, profile API, and group API requests + mux := http.NewServeMux() + mux.HandleFunc("/squad.xml", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, squadXMLBody) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("steamids") != "" { + // GetPlayerSummaries request + json.NewEncoder(w).Encode(steamProfileResponse{ + Response: struct { + Players []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + } `json:"players"` + }{ + Players: []struct { + PersonaName string `json:"personaname"` + AvatarURL string `json:"avatarmedium"` + }{ + {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, + }, + }, + }) + } + }) + srv := httptest.NewServer(mux) + + hdlr := Handler{ + setting: Setting{ + Secret: "test-secret", + Auth: Auth{ + Mode: "squadXml", + SessionTTL: time.Hour, + AdminSteamIDs: adminIDs, + SteamAPIKey: "TESTKEY", + SquadXmlURL: srv.URL + "/squad.xml", + SquadXmlCacheTTL: 5 * time.Minute, + }, + }, + jwt: NewJWTManager("test-secret", time.Hour), + openIDCache: openid.NewSimpleDiscoveryCache(), + openIDNonceStore: openid.NewSimpleNonceStore(), + openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/" + steamID}, + steamAPIBaseURL: srv.URL, + } + hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL) + + return hdlr, srv +} + +func TestSteamCallback_SquadXml_MemberGetsToken(t *testing.T) { + steamID := "76561198012345678" + hdlr, srv := newSquadXmlHandler(steamID, nil, []string{steamID, "76561198099999999"}) + defer srv.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "viewer", claims.Role) +} + +func TestSteamCallback_SquadXml_NonMemberGetsError(t *testing.T) { + steamID := "76561198012345678" + // Squad XML only contains a different user + hdlr, srv := newSquadXmlHandler(steamID, nil, []string{"76561198099999999"}) + defer srv.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_error=not_a_member") + assert.NotContains(t, loc, "auth_token=") +} + +func TestSteamCallback_SquadXml_AdminBypassesCheck(t *testing.T) { + steamID := "76561198012345678" + // Squad XML has NO members — if check runs, it would reject + hdlr, srv := newSquadXmlHandler(steamID, []string{steamID}, []string{}) + defer srv.Close() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "admin", claims.Role) +} + +func TestSteamCallback_SquadXml_FetchFailureRedirectsWithError(t *testing.T) { + steamID := "76561198012345678" + hdlr, srv := newSquadXmlHandler(steamID, nil, []string{steamID}) + defer srv.Close() + + // Replace the squad XML checker with one pointing to a dead server + hdlr.squadXml = newSquadXmlChecker("http://127.0.0.1:1/squad.xml", 5*time.Minute) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_error=membership_check_failed") +} From e0d6ecce24a431d3f43f3bde4acff6cc8d59b1c3 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 14:57:53 +0100 Subject: [PATCH 08/16] feat: add GET /api/v1/auth/config endpoint to expose auth mode The frontend needs to know the current auth mode to show appropriate login controls (password field vs Steam button vs nothing). This adds a public endpoint that returns the configured mode as JSON. --- internal/server/handler.go | 1 + internal/server/handler_auth.go | 11 ++++++ internal/server/handler_auth_test.go | 57 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/internal/server/handler.go b/internal/server/handler.go index 0a32fcc4..8f2d3fc2 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -169,6 +169,7 @@ func NewHandler( fuego.GetStd(g, "/images/maps/{path...}", hdlr.GetMapTile, fuego.OptionTags("Assets"), fuego.OptionMiddleware(cacheMiddleware)) // Auth + fuego.Get(g, "/api/v1/auth/config", hdlr.GetAuthConfig, fuego.OptionTags("Auth")) fuego.GetStd(g, "/api/v1/auth/steam", hdlr.SteamLogin, fuego.OptionTags("Auth")) fuego.GetStd(g, "/api/v1/auth/steam/callback", hdlr.SteamCallback, fuego.OptionTags("Auth")) fuego.PostStd(g, "/api/v1/auth/password", hdlr.PasswordLogin, fuego.OptionTags("Auth")) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 392ebe91..4c5a00d7 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -238,6 +238,17 @@ func (h *Handler) GetMe(c ContextNoBody) (MeResponse, error) { return resp, nil } +// AuthConfigResponse describes the authentication configuration returned by GetAuthConfig. +type AuthConfigResponse struct { + Mode string `json:"mode"` +} + +// GetAuthConfig returns the current authentication mode so the frontend +// can show the appropriate login controls. +func (h *Handler) GetAuthConfig(c ContextNoBody) (AuthConfigResponse, error) { + return AuthConfigResponse{Mode: h.setting.Auth.Mode}, nil +} + // Logout is a no-op for stateless JWT — the frontend discards the token. func (h *Handler) Logout(c ContextNoBody) (any, error) { c.SetStatus(http.StatusNoContent) diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 4b4a57f6..e1a1f78c 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -1244,6 +1244,63 @@ func TestSteamCallback_SquadXml_AdminBypassesCheck(t *testing.T) { assert.Equal(t, "admin", claims.Role) } +// --- GetAuthConfig tests --- + +func TestGetAuthConfig_ReturnsSteamGroupMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "steamGroup"}}, + } + + ctx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAuthConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "steamGroup", resp.Mode) +} + +func TestGetAuthConfig_ReturnsPublicMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "public"}}, + } + + ctx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAuthConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "public", resp.Mode) +} + +func TestGetAuthConfig_ReturnsPasswordMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "password"}}, + } + + ctx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAuthConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "password", resp.Mode) +} + +func TestGetAuthConfig_ReturnsSteamMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{Auth: Auth{Mode: "steam"}}, + } + + ctx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAuthConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "steam", resp.Mode) +} + +func TestGetAuthConfig_ReturnsEmptyWhenNotSet(t *testing.T) { + hdlr := Handler{ + setting: Setting{}, + } + + ctx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAuthConfig(ctx) + require.NoError(t, err) + assert.Equal(t, "", resp.Mode) +} + func TestSteamCallback_SquadXml_FetchFailureRedirectsWithError(t *testing.T) { steamID := "76561198012345678" hdlr, srv := newSquadXmlHandler(steamID, nil, []string{steamID}) From 7b31f521d340b814c8827cfbab908fd683921820 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 15:01:28 +0100 Subject: [PATCH 09/16] feat: add auth config, password login, and auth headers to API client Add getAuthConfig() and passwordLogin() methods to ApiClient. Include JWT auth headers in fetchJson/fetchBuffer so viewer-gated endpoints work in non-public modes. On 401 responses, save the current path and redirect to root for login. --- ui/src/data/__tests__/apiClient.test.ts | 158 +++++++++++++++++++++++- ui/src/data/apiClient.ts | 53 +++++++- 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/ui/src/data/__tests__/apiClient.test.ts b/ui/src/data/__tests__/apiClient.test.ts index f44009cd..27774969 100644 --- a/ui/src/data/__tests__/apiClient.test.ts +++ b/ui/src/data/__tests__/apiClient.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { ApiClient, ApiError, setAuthToken, getAuthToken } from "../apiClient"; -import type { CustomizeConfig, BuildInfo } from "../apiClient"; +import type { CustomizeConfig, BuildInfo, AuthConfig } from "../apiClient"; // ─── Helpers ─── @@ -161,7 +161,7 @@ describe("ApiClient", () => { const client = new ApiClient("/aar/"); const result = await client.getRecordingData("my_mission"); - expect(fetch).toHaveBeenCalledWith("/aar/data/my_mission.json.gz"); + expect(fetch).toHaveBeenCalledWith("/aar/data/my_mission.json.gz", expect.anything()); expect(new Uint8Array(result)).toEqual(new Uint8Array([1, 2, 3, 4])); }); @@ -476,6 +476,7 @@ describe("ApiClient", () => { expect(fetch).toHaveBeenCalledWith( "/aar/data/op-123/manifest.pb", + expect.anything(), ); expect(new Uint8Array(result)).toEqual(new Uint8Array([10, 20, 30])); }); @@ -491,6 +492,7 @@ describe("ApiClient", () => { expect(fetch).toHaveBeenCalledWith( "/aar/data/op-123/chunks/0005.pb", + expect.anything(), ); expect(new Uint8Array(result)).toEqual(new Uint8Array([0xaa, 0xbb])); }); @@ -1107,4 +1109,156 @@ describe("ApiClient", () => { await promise; }); }); + + // ─── getAuthConfig ─── + + describe("getAuthConfig", () => { + it("returns auth mode from server", async () => { + mockFetchJson({ mode: "password" }); + + const client = new ApiClient("/aar/"); + const result = await client.getAuthConfig(); + + expect(fetch).toHaveBeenCalledWith("/aar/api/v1/auth/config", { + cache: "no-cache", + }); + expect(result).toEqual({ mode: "password" }); + }); + + it("defaults to public mode on error", async () => { + mockFetchError(500, "Internal Server Error"); + + const client = new ApiClient("/aar/"); + const result = await client.getAuthConfig(); + + expect(result).toEqual({ mode: "public" }); + }); + }); + + // ─── passwordLogin ─── + + describe("passwordLogin", () => { + it("stores token on success", async () => { + mockFetchJson({ token: "pw-jwt-token" }); + + const client = new ApiClient("/aar/"); + const token = await client.passwordLogin("secret123"); + + expect(fetch).toHaveBeenCalledWith("/aar/api/v1/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "secret123" }), + }); + expect(token).toBe("pw-jwt-token"); + expect(getAuthToken()).toBe("pw-jwt-token"); + }); + + it("throws 'Invalid password' on 401", async () => { + mockFetchError(401, "Unauthorized"); + + const client = new ApiClient("/aar/"); + await expect(client.passwordLogin("wrong")).rejects.toThrow("Invalid password"); + }); + + it("throws 'Login failed' on other errors", async () => { + mockFetchError(500, "Internal Server Error"); + + const client = new ApiClient("/aar/"); + await expect(client.passwordLogin("test")).rejects.toThrow("Login failed"); + }); + }); + + // ─── Auth headers on viewer-gated endpoints ─── + + describe("auth headers on viewer-gated endpoints", () => { + it("includes auth header in fetchJson calls when token is set", async () => { + setAuthToken("viewer-jwt"); + mockFetchJson([]); + + const client = new ApiClient("/aar/"); + await client.getRecordings(); + + expect(fetch).toHaveBeenCalledWith( + "/aar/api/v1/operations", + expect.objectContaining({ + headers: { Authorization: "Bearer viewer-jwt" }, + }), + ); + }); + + it("includes auth header in fetchBuffer calls when token is set", async () => { + setAuthToken("viewer-jwt"); + mockFetchBuffer(new ArrayBuffer(0)); + + const client = new ApiClient("/aar/"); + await client.getRecordingData("test"); + + expect(fetch).toHaveBeenCalledWith( + "/aar/data/test.json.gz", + expect.objectContaining({ + headers: { Authorization: "Bearer viewer-jwt" }, + }), + ); + }); + + it("sends empty headers when no token is stored", async () => { + mockFetchJson([]); + + const client = new ApiClient("/aar/"); + await client.getRecordings(); + + expect(fetch).toHaveBeenCalledWith( + "/aar/api/v1/operations", + expect.objectContaining({ + headers: {}, + }), + ); + }); + + it("saves return path and redirects on 401 from fetchJson", async () => { + mockFetchError(401, "Unauthorized"); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + pathname: "/recording/42/test", + search: "", + get href() { return "http://localhost/recording/42/test"; }, + set href(v: string) { hrefSetter(v); }, + }, + writable: true, + configurable: true, + }); + + const client = new ApiClient("/aar/"); + await expect(client.getRecordings()).rejects.toThrow("Authentication required"); + + expect(sessionStorage.getItem("ocap_return_to")).toBe("/recording/42/test"); + expect(hrefSetter).toHaveBeenCalledWith("/"); + }); + + it("saves return path and redirects on 401 from fetchBuffer", async () => { + mockFetchError(401, "Unauthorized"); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + pathname: "/recording/7/mission", + search: "?t=100", + get href() { return "http://localhost/recording/7/mission?t=100"; }, + set href(v: string) { hrefSetter(v); }, + }, + writable: true, + configurable: true, + }); + + const client = new ApiClient("/aar/"); + await expect(client.getRecordingData("test")).rejects.toThrow("Authentication required"); + + expect(sessionStorage.getItem("ocap_return_to")).toBe("/recording/7/mission?t=100"); + expect(hrefSetter).toHaveBeenCalledWith("/"); + }); + }); }); diff --git a/ui/src/data/apiClient.ts b/ui/src/data/apiClient.ts index 95f70dc0..4b2a28f0 100644 --- a/ui/src/data/apiClient.ts +++ b/ui/src/data/apiClient.ts @@ -3,6 +3,10 @@ import type { ToolSet, HealthCheck, MapInfo, JobInfo } from "../pages/map-manage // ─── Response types for endpoints not covered in types.ts ─── +export interface AuthConfig { + mode: string; +} + export interface CustomizeConfig { websiteURL?: string; websiteLogo?: string; @@ -333,6 +337,34 @@ export class ApiClient { return true; } + async getAuthConfig(): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/auth/config`, { + cache: "no-cache", + }); + if (!response.ok) { + return { mode: "public" }; + } + return response.json() as Promise; + } + + async passwordLogin(password: string): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/auth/password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (!response.ok) { + throw new ApiError( + response.status === 401 ? "Invalid password" : "Login failed", + response.status, + response.statusText, + ); + } + const data = (await response.json()) as { token: string }; + setAuthToken(data.token); + return data.token; + } + async getMe(): Promise { const response = await fetch(`${this.baseUrl}/api/v1/auth/me`, { headers: authHeaders(), @@ -590,7 +622,16 @@ export class ApiClient { } private async fetchJson(url: string): Promise { - const response = await fetch(url, { cache: "no-store" }); + const response = await fetch(url, { + headers: authHeaders(), + cache: "no-store", + }); + if (response.status === 401) { + sessionStorage.setItem("ocap_return_to", window.location.pathname + window.location.search); + const base = ((globalThis as Record).__BASE_PATH__ as string) ?? ""; + window.location.href = base + "/"; + throw new ApiError("Authentication required", 401, "Unauthorized"); + } if (!response.ok) { throw new ApiError( `GET ${url} failed: ${response.status} ${response.statusText}`, @@ -602,7 +643,15 @@ export class ApiClient { } private async fetchBuffer(url: string): Promise { - const response = await fetch(url); + const response = await fetch(url, { + headers: authHeaders(), + }); + if (response.status === 401) { + sessionStorage.setItem("ocap_return_to", window.location.pathname + window.location.search); + const base = ((globalThis as Record).__BASE_PATH__ as string) ?? ""; + window.location.href = base + "/"; + throw new ApiError("Authentication required", 401, "Unauthorized"); + } if (!response.ok) { throw new ApiError( `GET ${url} failed: ${response.status} ${response.statusText}`, From 3f814dfde621192302f65e5a4b51da04ec95608f Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 15:04:49 +0100 Subject: [PATCH 10/16] feat: auth-gated UI with password login and auth mode support Add authMode signal and loginWithPassword action to useAuth provider. AuthBadge shows password form in password mode alongside Steam button. Auth errors (not_a_member, membership_check_failed) are mapped to user-friendly messages. Fix RecordingSelector test to scope text query within auth-toast to avoid duplicate match. --- ui/src/components/AuthBadge.module.css | 55 +++++++++ ui/src/components/AuthBadge.tsx | 50 ++++++++- .../components/__tests__/AuthBadge.test.tsx | 56 +++++++++- ui/src/hooks/__tests__/useAuth.test.tsx | 104 ++++++++++++++++++ ui/src/hooks/useAuth.tsx | 30 ++++- .../__tests__/RecordingSelector.test.tsx | 5 +- 6 files changed, 290 insertions(+), 10 deletions(-) diff --git a/ui/src/components/AuthBadge.module.css b/ui/src/components/AuthBadge.module.css index 0f3b318d..9001fd4b 100644 --- a/ui/src/components/AuthBadge.module.css +++ b/ui/src/components/AuthBadge.module.css @@ -87,3 +87,58 @@ filter: brightness(1.1); border-color: rgba(255, 255, 255, 0.15); } + +.authControls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.passwordForm { + display: flex; + gap: 0.25rem; +} + +.passwordInput { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--input-bg, #1a1a2e); + color: var(--text-color, #e0e0e0); + font-size: 0.8rem; + width: 140px; +} + +.passwordSubmit { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--accent-color, #4a9eff); + color: white; + font-size: 0.8rem; + cursor: pointer; +} + +.passwordSubmit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.authError { + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--error-color, #ff4444); + font-size: 0.75rem; + max-width: 250px; +} + +.dismissError { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + padding: 0; + line-height: 1; +} diff --git a/ui/src/components/AuthBadge.tsx b/ui/src/components/AuthBadge.tsx index 200c9e29..37c310cc 100644 --- a/ui/src/components/AuthBadge.tsx +++ b/ui/src/components/AuthBadge.tsx @@ -1,4 +1,4 @@ -import { Show } from "solid-js"; +import { Show, createSignal } from "solid-js"; import type { JSX } from "solid-js"; import { useAuth } from "../hooks/useAuth"; import { useI18n } from "../hooks/useLocale"; @@ -6,21 +6,59 @@ import { SteamIcon, ShieldIcon, LogOutIcon } from "./Icons"; import styles from "./AuthBadge.module.css"; /** - * Shared auth badge — renders Steam sign-in when unauthenticated, + * Shared auth badge — renders login controls when unauthenticated, * admin badge + sign-out when authenticated. + * Shows password form in password mode, Steam button in all other modes. * Calls useAuth() internally; no props needed. */ export function AuthBadge(): JSX.Element { - const { authenticated, isAdmin, steamName, steamId, steamAvatar, loginWithSteam, logout } = useAuth(); + const { authenticated, isAdmin, steamName, steamId, steamAvatar, authMode, authError, dismissAuthError, loginWithSteam, loginWithPassword, logout } = useAuth(); const { t } = useI18n(); + const [password, setPassword] = createSignal(""); + const [loading, setLoading] = createSignal(false); + + const handlePasswordSubmit = async (e: Event) => { + e.preventDefault(); + if (!password()) return; + setLoading(true); + try { + await loginWithPassword(password()); + } finally { + setLoading(false); + setPassword(""); + } + }; return ( loginWithSteam()}> - {t("sign_in")} - +
+ +
+ setPassword(e.currentTarget.value)} + class={styles.passwordInput} + disabled={loading()} + /> + +
+
+ + +
+ {authError()} + +
+
+
} > <> diff --git a/ui/src/components/__tests__/AuthBadge.test.tsx b/ui/src/components/__tests__/AuthBadge.test.tsx index 7a25691d..26c8e4a6 100644 --- a/ui/src/components/__tests__/AuthBadge.test.tsx +++ b/ui/src/components/__tests__/AuthBadge.test.tsx @@ -6,6 +6,7 @@ import { I18nProvider } from "../../hooks/useLocale"; // ─── Mock useAuth ─── const mockLoginWithSteam = vi.fn(); +const mockLoginWithPassword = vi.fn().mockResolvedValue(undefined); const mockLogout = vi.fn(); const authState = { @@ -15,9 +16,11 @@ const authState = { steamName: vi.fn(() => null as string | null), steamId: vi.fn(() => null as string | null), steamAvatar: vi.fn(() => null as string | null), - authError: vi.fn(() => null), + authError: vi.fn(() => null as string | null), + authMode: vi.fn(() => "public"), dismissAuthError: vi.fn(), loginWithSteam: mockLoginWithSteam, + loginWithPassword: mockLoginWithPassword, logout: mockLogout, }; @@ -37,6 +40,8 @@ describe("AuthBadge", () => { authState.steamName.mockReturnValue(null); authState.steamId.mockReturnValue(null); authState.steamAvatar.mockReturnValue(null); + authState.authError.mockReturnValue(null); + authState.authMode.mockReturnValue("public"); }); it("shows sign-in button when not authenticated", () => { @@ -111,4 +116,53 @@ describe("AuthBadge", () => { fireEvent.click(getByTitle("Sign out")); expect(mockLogout).toHaveBeenCalledOnce(); }); + + // ─── Password mode ─── + + it("shows password field and Steam button in password mode", () => { + authState.authMode.mockReturnValue("password"); + + const { getByPlaceholderText, getByText } = render(() => ); + expect(getByPlaceholderText("Password")).toBeDefined(); + expect(getByText("Unlock")).toBeDefined(); + expect(getByText("Sign in")).toBeDefined(); + }); + + it("shows only Steam button in steam mode", () => { + authState.authMode.mockReturnValue("steam"); + + const { getByText, queryByPlaceholderText } = render(() => ); + expect(getByText("Sign in")).toBeDefined(); + expect(queryByPlaceholderText("Password")).toBeNull(); + }); + + it("shows only Steam button in public mode", () => { + authState.authMode.mockReturnValue("public"); + + const { getByText, queryByPlaceholderText } = render(() => ); + expect(getByText("Sign in")).toBeDefined(); + expect(queryByPlaceholderText("Password")).toBeNull(); + }); + + it("calls loginWithPassword on form submit", async () => { + authState.authMode.mockReturnValue("password"); + + const { getByPlaceholderText, getByText } = render(() => ); + + const input = getByPlaceholderText("Password") as HTMLInputElement; + fireEvent.input(input, { target: { value: "secret123" } }); + fireEvent.click(getByText("Unlock")); + + expect(mockLoginWithPassword).toHaveBeenCalledWith("secret123"); + }); + + it("displays auth error and dismiss button", () => { + authState.authError.mockReturnValue("Invalid password"); + + const { getByText } = render(() => ); + expect(getByText("Invalid password")).toBeDefined(); + + fireEvent.click(getByText("x")); + expect(authState.dismissAuthError).toHaveBeenCalledOnce(); + }); }); diff --git a/ui/src/hooks/__tests__/useAuth.test.tsx b/ui/src/hooks/__tests__/useAuth.test.tsx index 723283b3..d9782945 100644 --- a/ui/src/hooks/__tests__/useAuth.test.tsx +++ b/ui/src/hooks/__tests__/useAuth.test.tsx @@ -11,6 +11,8 @@ const mockLogout = vi.fn(); const mockGetSteamLoginUrl = vi.fn().mockReturnValue("/api/v1/auth/steam"); const mockConsumeAuthToken = vi.fn().mockReturnValue(false); const mockPopReturnTo = vi.fn().mockReturnValue(null); +const mockGetAuthConfig = vi.fn().mockResolvedValue({ mode: "public" }); +const mockPasswordLogin = vi.fn(); vi.mock("../../data/apiClient", async () => { const actual = await vi.importActual("../../data/apiClient"); @@ -22,6 +24,8 @@ vi.mock("../../data/apiClient", async () => { getSteamLoginUrl = mockGetSteamLoginUrl; consumeAuthToken = mockConsumeAuthToken; popReturnTo = mockPopReturnTo; + getAuthConfig = mockGetAuthConfig; + passwordLogin = mockPasswordLogin; }, }; }); @@ -50,6 +54,8 @@ describe("useAuth", () => { mockLogout.mockResolvedValue(undefined); mockConsumeAuthToken.mockReturnValue(false); mockPopReturnTo.mockReturnValue(null); + mockGetAuthConfig.mockResolvedValue({ mode: "public" }); + mockPasswordLogin.mockResolvedValue("pw-token"); }); afterEach(() => { @@ -284,4 +290,102 @@ describe("useAuth", () => { expect(authRef.role()).toBeNull(); expect(authRef.isAdmin()).toBe(false); }); + + it("authMode defaults to public", async () => { + let authRef!: Auth; + const { findByTestId } = renderAuth((a) => { authRef = a; }); + + await findByTestId("authenticated"); + await vi.waitFor(() => { + expect(authRef.authMode()).toBe("public"); + }); + }); + + it("authMode reflects server config", async () => { + mockGetAuthConfig.mockResolvedValue({ mode: "password" }); + + let authRef!: Auth; + const { findByTestId } = renderAuth((a) => { authRef = a; }); + + await findByTestId("authenticated"); + await vi.waitFor(() => { + expect(authRef.authMode()).toBe("password"); + }); + }); + + it("authMode defaults to public when getAuthConfig fails", async () => { + mockGetAuthConfig.mockRejectedValue(new Error("network error")); + + let authRef!: Auth; + const { findByTestId } = renderAuth((a) => { authRef = a; }); + + await findByTestId("authenticated"); + await vi.waitFor(() => { + expect(authRef.authMode()).toBe("public"); + }); + }); + + it("loginWithPassword success sets authenticated state", async () => { + mockPasswordLogin.mockResolvedValue("pw-token"); + mockGetMe.mockResolvedValue({ + authenticated: true, + role: "viewer", + steamId: null, + steamName: null, + steamAvatar: null, + }); + + let authRef!: Auth; + const { findByTestId } = renderAuth((a) => { authRef = a; }); + + await findByTestId("authenticated"); + await authRef.loginWithPassword("secret123"); + + expect(mockPasswordLogin).toHaveBeenCalledWith("secret123"); + expect(authRef.authenticated()).toBe(true); + expect(authRef.role()).toBe("viewer"); + }); + + it("loginWithPassword failure sets authError", async () => { + mockPasswordLogin.mockRejectedValue(new Error("Invalid password")); + + let authRef!: Auth; + const { findByTestId } = renderAuth((a) => { authRef = a; }); + + await findByTestId("authenticated"); + await authRef.loginWithPassword("wrong"); + + expect(authRef.authError()).toBe("Invalid password"); + expect(authRef.authenticated()).toBe(false); + }); + + it("maps not_a_member auth error", async () => { + Object.defineProperty(window, "location", { + value: { ...window.location, search: "?auth_error=not_a_member", href: window.location.origin + "/?auth_error=not_a_member", pathname: "/" }, + writable: true, + configurable: true, + }); + + let authRef!: Auth; + renderAuth((a) => { authRef = a; }); + + await vi.waitFor(() => { + expect(authRef.authError()).toBe("You are not a member of this community. Contact an admin for access."); + }); + }); + + it("maps membership_check_failed auth error", async () => { + Object.defineProperty(window, "location", { + value: { ...window.location, search: "?auth_error=membership_check_failed", href: window.location.origin + "/?auth_error=membership_check_failed", pathname: "/" }, + writable: true, + configurable: true, + }); + + let authRef!: Auth; + renderAuth((a) => { authRef = a; }); + + await vi.waitFor(() => { + expect(authRef.authError()).toBe("Could not verify membership. Please try again later."); + }); + }); }); diff --git a/ui/src/hooks/useAuth.tsx b/ui/src/hooks/useAuth.tsx index 15b0c1d7..ff284b1f 100644 --- a/ui/src/hooks/useAuth.tsx +++ b/ui/src/hooks/useAuth.tsx @@ -10,13 +10,17 @@ export interface Auth { steamName: Accessor; steamAvatar: Accessor; authError: Accessor; + authMode: Accessor; dismissAuthError: () => void; loginWithSteam: () => void; + loginWithPassword: (password: string) => Promise; logout: () => Promise; } const AUTH_ERROR_MESSAGES: Record = { steam_error: "Steam login failed. Please try again.", + not_a_member: "You are not a member of this community. Contact an admin for access.", + membership_check_failed: "Could not verify membership. Please try again later.", }; const AuthContext = createContext(); @@ -32,9 +36,18 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { const [steamName, setSteamName] = createSignal(null); const [steamAvatar, setSteamAvatar] = createSignal(null); const [authError, setAuthError] = createSignal(null); + const [authMode, setAuthMode] = createSignal("public"); const api = new ApiClient(); onMount(async () => { + // Fetch auth config first + try { + const config = await api.getAuthConfig(); + setAuthMode(config.mode); + } catch { + // Default to public if endpoint fails + } + // Read query params from Steam callback redirect const params = new URLSearchParams(window.location.search); @@ -78,6 +91,21 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { const dismissAuthError = () => setAuthError(null); + const loginWithPassword = async (password: string): Promise => { + setAuthError(null); + try { + await api.passwordLogin(password); + const state = await api.getMe(); + setAuthenticated(state.authenticated); + setRole(state.role ?? null); + setSteamId(state.steamId ?? null); + setSteamName(state.steamName ?? null); + setSteamAvatar(state.steamAvatar ?? null); + } catch (err) { + setAuthError(err instanceof Error ? err.message : "Login failed"); + } + }; + const loginWithSteam = () => { setAuthError(null); window.location.href = api.getSteamLoginUrl( @@ -98,7 +126,7 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { }; return ( - + {props.children} ); diff --git a/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx b/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx index b4b4e310..e824cfa9 100644 --- a/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx +++ b/ui/src/pages/recording-selector/__tests__/RecordingSelector.test.tsx @@ -632,8 +632,9 @@ describe("RecordingSelector", () => { // Wait for toast to appear await vi.waitFor(() => { - expect(screen.getByTestId("auth-toast")).toBeDefined(); - expect(screen.getByText(/Steam login failed/)).toBeDefined(); + const toast = screen.getByTestId("auth-toast"); + expect(toast).toBeDefined(); + expect(within(toast).getByText(/Steam login failed/)).toBeDefined(); }); // Advance past the 5s auto-dismiss timeout From 15eee5ac5e76cf56abbb21b23e04968df119d192 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 15:10:11 +0100 Subject: [PATCH 11/16] fix(auth): timing-safe password comparison and mode guard - Use crypto/subtle.ConstantTimeCompare for password validation - Reject password login requests when auth.mode is not "password" - Add test for wrong-mode rejection --- internal/server/handler_auth.go | 8 +++++++- internal/server/handler_auth_test.go | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 4c5a00d7..f2b9716b 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -2,6 +2,7 @@ package server import ( "crypto/rand" + "crypto/subtle" "encoding/hex" "encoding/json" "encoding/xml" @@ -190,6 +191,11 @@ func (h *Handler) authRedirect(w http.ResponseWriter, r *http.Request, query str // PasswordLogin validates a shared password and issues a viewer JWT. func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) { + if h.setting.Auth.Mode != "password" { + http.Error(w, "password login not enabled", http.StatusNotFound) + return + } + var req struct { Password string `json:"password"` } @@ -198,7 +204,7 @@ func (h *Handler) PasswordLogin(w http.ResponseWriter, r *http.Request) { return } - if req.Password == "" || req.Password != h.setting.Auth.Password { + if req.Password == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.setting.Auth.Password)) != 1 { http.Error(w, "invalid password", http.StatusUnauthorized) return } diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index e1a1f78c..a690eeed 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -629,6 +629,7 @@ func newPasswordAuthHandler(password string) Handler { setting: Setting{ Secret: "test-secret", Auth: Auth{ + Mode: "password", SessionTTL: time.Hour, Password: password, }, @@ -706,6 +707,22 @@ func TestPasswordLogin_MissingBody(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } +func TestPasswordLogin_WrongMode(t *testing.T) { + hdlr := Handler{ + setting: Setting{ + Secret: "test-secret", + Auth: Auth{Mode: "steam", SessionTTL: time.Hour, Password: "s3cret"}, + }, + jwt: NewJWTManager("test-secret", time.Hour), + } + body := strings.NewReader(`{"password":"s3cret"}`) + req := httptest.NewRequest("POST", "/api/v1/auth/password", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + hdlr.PasswordLogin(rec, req) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + // --- checkSteamGroupMembership unit tests --- func TestCheckSteamGroupMembership_IsMember(t *testing.T) { From e7ab120b7ca99630f65feb02da98a8d54525e56c Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 16:25:27 +0100 Subject: [PATCH 12/16] refactor(auth): remove steamGroup and squadXml modes in favor of steamAllowlist --- internal/server/handler.go | 5 - internal/server/handler_auth.go | 130 ------ internal/server/handler_auth_test.go | 568 --------------------------- internal/server/setting.go | 38 +- internal/server/setting_test.go | 41 +- setting.json.example | 5 +- 6 files changed, 14 insertions(+), 773 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 8f2d3fc2..2506f59b 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -65,7 +65,6 @@ type Handler struct { openIDCache openid.DiscoveryCache openIDNonceStore openid.NonceStore steamAPIBaseURL string // override for testing; empty uses default - squadXml *squadXmlChecker spriteOnce sync.Once spriteFiles map[string][]byte @@ -133,10 +132,6 @@ func NewHandler( hdlr.openIDNonceStore = openid.NewSimpleNonceStore() hdlr.openIDVerifier = defaultOpenIDVerifier{} - if hdlr.setting.Auth.Mode == "squadXml" { - hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL) - } - prefixURL := strings.TrimRight(hdlr.setting.PrefixURL, "/") g := fuego.Group(s, prefixURL) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index f2b9716b..f212a080 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -5,14 +5,12 @@ import ( "crypto/subtle" "encoding/hex" "encoding/json" - "encoding/xml" "fmt" "log" "net/http" "net/url" "slices" "strings" - "sync" "time" "github.com/yohcop/openid-go" @@ -119,38 +117,6 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { role = "admin" } - // In steamGroup mode, check group membership (admins bypass) - if h.setting.Auth.Mode == "steamGroup" && role != "admin" { - baseURL := steamGroupAPIBaseURL - if h.steamAPIBaseURL != "" { - baseURL = h.steamAPIBaseURL - } - isMember, err := checkSteamGroupMembership(baseURL, steamID, h.setting.Auth.SteamAPIKey, h.setting.Auth.SteamGroupID) - if err != nil { - log.Printf("WARN: steam group membership check failed for %s: %v", steamID, err) - h.authRedirect(w, r, "auth_error=membership_check_failed") - return - } - if !isMember { - h.authRedirect(w, r, "auth_error=not_a_member") - return - } - } - - // In squadXml mode, check squad XML membership (admins bypass) - if h.setting.Auth.Mode == "squadXml" && role != "admin" { - isMember, err := h.squadXml.isMember(steamID) - if err != nil { - log.Printf("WARN: squad XML membership check failed for %s: %v", steamID, err) - h.authRedirect(w, r, "auth_error=membership_check_failed") - return - } - if !isMember { - h.authRedirect(w, r, "auth_error=not_a_member") - return - } - } - // Fetch Steam profile data if API key is configured claimOpts := []ClaimOption{WithRole(role)} if h.setting.Auth.SteamAPIKey != "" { @@ -333,7 +299,6 @@ type steamProfileResponse struct { } const steamAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/" -const steamGroupAPIBaseURL = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/" // fetchSteamProfileFrom calls the Steam Web API to get the player's display name and avatar. func fetchSteamProfileFrom(baseURL, steamID, apiKey string) (name, avatar string, err error) { @@ -363,45 +328,6 @@ func fetchSteamProfileFrom(baseURL, steamID, apiKey string) (name, avatar string return p.PersonaName, p.AvatarURL, nil } -// steamGroupListResponse models the Steam Web API GetUserGroupList response. -type steamGroupListResponse struct { - Response struct { - Success bool `json:"success"` - Groups []struct { - GID string `json:"gid"` - } `json:"groups"` - } `json:"response"` -} - -// checkSteamGroupMembership checks whether the given Steam user is a member -// of the specified Steam group by querying the Steam Web API. -func checkSteamGroupMembership(baseURL, steamID, apiKey, groupID string) (bool, error) { - u := baseURL + "?key=" + url.QueryEscape(apiKey) + "&steamid=" + url.QueryEscape(steamID) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(u) - if err != nil { - return false, fmt.Errorf("steam group API request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("steam group API error: status %d", resp.StatusCode) - } - - var data steamGroupListResponse - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return false, fmt.Errorf("steam group API decode error: %w", err) - } - - for _, g := range data.Response.Groups { - if g.GID == groupID { - return true, nil - } - } - return false, nil -} - func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { @@ -409,59 +335,3 @@ func randomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } - -// squadXmlChecker fetches and caches a remote Arma 3 squad XML, -// then checks membership by Steam ID. -type squadXmlChecker struct { - url string - cacheTTL time.Duration - - mu sync.Mutex - members map[string]bool - fetchedAt time.Time -} - -func newSquadXmlChecker(url string, cacheTTL time.Duration) *squadXmlChecker { - return &squadXmlChecker{url: url, cacheTTL: cacheTTL} -} - -type squadXml struct { - Members []squadXmlMember `xml:"member"` -} - -type squadXmlMember struct { - ID string `xml:"id,attr"` -} - -func (c *squadXmlChecker) isMember(steamID string) (bool, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.members != nil && c.cacheTTL > 0 && time.Since(c.fetchedAt) < c.cacheTTL { - return c.members[steamID], nil - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(c.url) - if err != nil { - return false, fmt.Errorf("squad XML fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("squad XML fetch error: status %d", resp.StatusCode) - } - - var squad squadXml - if err := xml.NewDecoder(resp.Body).Decode(&squad); err != nil { - return false, fmt.Errorf("squad XML parse error: %w", err) - } - - c.members = make(map[string]bool, len(squad.Members)) - for _, m := range squad.Members { - c.members[m.ID] = true - } - c.fetchedAt = time.Now() - - return c.members[steamID], nil -} diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index a690eeed..2f4cbb1d 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -8,7 +8,6 @@ import ( "net/http/httptest" "net/url" "strings" - "sync/atomic" "testing" "time" @@ -723,557 +722,8 @@ func TestPasswordLogin_WrongMode(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) } -// --- checkSteamGroupMembership unit tests --- - -func TestCheckSteamGroupMembership_IsMember(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "TESTKEY", r.URL.Query().Get("key")) - assert.Equal(t, "76561198012345678", r.URL.Query().Get("steamid")) - fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"},{"gid":"103582791460111111"}]}}`) - })) - defer srv.Close() - - isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") - require.NoError(t, err) - assert.True(t, isMember) -} - -func TestCheckSteamGroupMembership_NotAMember(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"}]}}`) - })) - defer srv.Close() - - isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460999999") - require.NoError(t, err) - assert.False(t, isMember) -} - -func TestCheckSteamGroupMembership_APIError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - })) - defer srv.Close() - - _, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "BADKEY", "103582791460111111") - assert.Error(t, err) - assert.Contains(t, err.Error(), "status 403") -} - -func TestCheckSteamGroupMembership_InvalidJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `not json`) - })) - defer srv.Close() - - _, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") - assert.Error(t, err) - assert.Contains(t, err.Error(), "decode error") -} - -func TestCheckSteamGroupMembership_ConnectionError(t *testing.T) { - _, err := checkSteamGroupMembership("http://127.0.0.1:1/", "76561198012345678", "TESTKEY", "103582791460111111") - assert.Error(t, err) - assert.Contains(t, err.Error(), "request failed") -} - -func TestCheckSteamGroupMembership_EmptyGroups(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, `{"response":{"success":true,"groups":[]}}`) - })) - defer srv.Close() - - isMember, err := checkSteamGroupMembership(srv.URL, "76561198012345678", "TESTKEY", "103582791460111111") - require.NoError(t, err) - assert.False(t, isMember) -} - -// --- SteamCallback integration tests for steamGroup mode --- - -func newSteamGroupHandler(steamID string, adminIDs []string, groupID string) (Handler, *httptest.Server) { - // Create a mock server that handles both profile API and group membership API requests - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - // Route based on query parameters: group API uses "steamid" (singular), profile API uses "steamids" (plural) - if q.Get("steamid") != "" { - // GetUserGroupList request — return groupID as a member group - fmt.Fprintf(w, `{"response":{"success":true,"groups":[{"gid":"%s"}]}}`, groupID) - } else if q.Get("steamids") != "" { - // GetPlayerSummaries request - json.NewEncoder(w).Encode(steamProfileResponse{ - Response: struct { - Players []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - } `json:"players"` - }{ - Players: []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - }{ - {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, - }, - }, - }) - } - }) - srv := httptest.NewServer(mux) - - hdlr := Handler{ - setting: Setting{ - Secret: "test-secret", - Auth: Auth{ - Mode: "steamGroup", - SessionTTL: time.Hour, - AdminSteamIDs: adminIDs, - SteamAPIKey: "TESTKEY", - SteamGroupID: groupID, - }, - }, - jwt: NewJWTManager("test-secret", time.Hour), - openIDCache: openid.NewSimpleDiscoveryCache(), - openIDNonceStore: openid.NewSimpleNonceStore(), - openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/" + steamID}, - steamAPIBaseURL: srv.URL, - } - - return hdlr, srv -} - -func TestSteamCallback_SteamGroup_MemberGetsToken(t *testing.T) { - hdlr, srv := newSteamGroupHandler("76561198012345678", nil, "103582791460111111") - defer srv.Close() - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_token=") - assert.NotContains(t, loc, "auth_error") - - u, err := url.Parse(loc) - require.NoError(t, err) - tokenValue := u.Query().Get("auth_token") - claims := hdlr.jwt.Claims(tokenValue) - require.NotNil(t, claims) - assert.Equal(t, "viewer", claims.Role) -} - -func TestSteamCallback_SteamGroup_NonMemberGetsError(t *testing.T) { - // The mock server returns groupID "103582791460111111" as the user's group, - // but we configure the handler to require "999999999999999999" - hdlr, srv := newSteamGroupHandler("76561198012345678", nil, "999999999999999999") - defer srv.Close() - - // Override the mock to return a different group than what's required - srv.Close() - nonMemberSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("steamid") != "" { - // Return a group that doesn't match the required one - fmt.Fprint(w, `{"response":{"success":true,"groups":[{"gid":"103582791460000000"}]}}`) - } else if q.Get("steamids") != "" { - json.NewEncoder(w).Encode(steamProfileResponse{ - Response: struct { - Players []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - } `json:"players"` - }{ - Players: []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - }{ - {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, - }, - }, - }) - } - })) - defer nonMemberSrv.Close() - hdlr.steamAPIBaseURL = nonMemberSrv.URL - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_error=not_a_member") - assert.NotContains(t, loc, "auth_token=") -} - -func TestSteamCallback_SteamGroup_AdminBypassesGroupCheck(t *testing.T) { - // Admin's steam ID is in the admin list; group check should be skipped entirely - // Use a group ID that doesn't match any group the user is in, to prove bypass - steamID := "76561198012345678" - hdlr, srv := newSteamGroupHandler(steamID, []string{steamID}, "999999999999999999") - defer srv.Close() - - // Override to return no matching group — if group check runs, it would fail - srv.Close() - noGroupSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("steamid") != "" { - // Return empty groups — would fail membership check - fmt.Fprint(w, `{"response":{"success":true,"groups":[]}}`) - } else if q.Get("steamids") != "" { - json.NewEncoder(w).Encode(steamProfileResponse{ - Response: struct { - Players []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - } `json:"players"` - }{ - Players: []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - }{ - {PersonaName: "AdminPlayer", AvatarURL: "https://example.com/admin.jpg"}, - }, - }, - }) - } - })) - defer noGroupSrv.Close() - hdlr.steamAPIBaseURL = noGroupSrv.URL - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_token=") - assert.NotContains(t, loc, "auth_error") - - u, err := url.Parse(loc) - require.NoError(t, err) - tokenValue := u.Query().Get("auth_token") - claims := hdlr.jwt.Claims(tokenValue) - require.NotNil(t, claims) - assert.Equal(t, "admin", claims.Role) -} - -func TestSteamCallback_SteamGroup_APIFailureRedirectsWithError(t *testing.T) { - steamID := "76561198012345678" - hdlr, srv := newSteamGroupHandler(steamID, nil, "103582791460111111") - defer srv.Close() - - // Replace with a server that returns 500 for group check - srv.Close() - failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("steamid") != "" { - w.WriteHeader(http.StatusInternalServerError) - } else if q.Get("steamids") != "" { - // Profile fetch still works - json.NewEncoder(w).Encode(steamProfileResponse{ - Response: struct { - Players []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - } `json:"players"` - }{ - Players: []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - }{ - {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, - }, - }, - }) - } - })) - defer failSrv.Close() - hdlr.steamAPIBaseURL = failSrv.URL - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_error=membership_check_failed") -} - -func TestSteamCallback_NonSteamGroupMode_SkipsGroupCheck(t *testing.T) { - // In "steam" mode (not "steamGroup"), group membership check should NOT run - hdlr := newSteamAuthHandler([]string{}) - hdlr.setting.Auth.Mode = "steam" - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_token=") - assert.NotContains(t, loc, "auth_error") -} - -// --- squadXmlChecker unit tests --- - -const testSquadXML = ` - - - Test Group - - -` - -func TestSquadXmlChecker_MemberFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, testSquadXML) - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 5*time.Minute) - isMember, err := checker.isMember("76561198012345678") - require.NoError(t, err) - assert.True(t, isMember) -} - -func TestSquadXmlChecker_NonMemberNotFound(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, testSquadXML) - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 5*time.Minute) - isMember, err := checker.isMember("76561198000000000") - require.NoError(t, err) - assert.False(t, isMember) -} - -func TestSquadXmlChecker_CachePreventsRefetch(t *testing.T) { - var fetchCount atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fetchCount.Add(1) - fmt.Fprint(w, testSquadXML) - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 5*time.Minute) - - // First call fetches - _, err := checker.isMember("76561198012345678") - require.NoError(t, err) - assert.Equal(t, int32(1), fetchCount.Load()) - - // Second call should use cache (same TTL, no expiry) - _, err = checker.isMember("76561198099999999") - require.NoError(t, err) - assert.Equal(t, int32(1), fetchCount.Load(), "should not refetch when cache is valid") -} - -func TestSquadXmlChecker_ZeroTTL_AlwaysRefetches(t *testing.T) { - var fetchCount atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fetchCount.Add(1) - fmt.Fprint(w, testSquadXML) - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 0) // zero TTL = always refetch - - _, err := checker.isMember("76561198012345678") - require.NoError(t, err) - assert.Equal(t, int32(1), fetchCount.Load()) - - _, err = checker.isMember("76561198012345678") - require.NoError(t, err) - assert.Equal(t, int32(2), fetchCount.Load(), "should refetch every time with zero TTL") -} - -func TestSquadXmlChecker_HTTPError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 5*time.Minute) - _, err := checker.isMember("76561198012345678") - assert.Error(t, err) - assert.Contains(t, err.Error(), "status 500") -} - -func TestSquadXmlChecker_InvalidXML(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, "not valid xml <><><<") - })) - defer srv.Close() - - checker := newSquadXmlChecker(srv.URL, 5*time.Minute) - _, err := checker.isMember("76561198012345678") - assert.Error(t, err) - assert.Contains(t, err.Error(), "parse error") -} - -func TestSquadXmlChecker_ConnectionError(t *testing.T) { - checker := newSquadXmlChecker("http://127.0.0.1:1/", 5*time.Minute) - _, err := checker.isMember("76561198012345678") - assert.Error(t, err) - assert.Contains(t, err.Error(), "fetch failed") -} - -// --- SteamCallback integration tests for squadXml mode --- - -func newSquadXmlHandler(steamID string, adminIDs []string, squadMembers []string) (Handler, *httptest.Server) { - // Build squad XML from member list - var members strings.Builder - for _, id := range squadMembers { - fmt.Fprintf(&members, ` `+"\n", id) - } - squadXMLBody := fmt.Sprintf(` - - Test Group -%s`, members.String()) - - // Create a mock server that handles squad XML, profile API, and group API requests - mux := http.NewServeMux() - mux.HandleFunc("/squad.xml", func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, squadXMLBody) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("steamids") != "" { - // GetPlayerSummaries request - json.NewEncoder(w).Encode(steamProfileResponse{ - Response: struct { - Players []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - } `json:"players"` - }{ - Players: []struct { - PersonaName string `json:"personaname"` - AvatarURL string `json:"avatarmedium"` - }{ - {PersonaName: "TestPlayer", AvatarURL: "https://example.com/avatar.jpg"}, - }, - }, - }) - } - }) - srv := httptest.NewServer(mux) - - hdlr := Handler{ - setting: Setting{ - Secret: "test-secret", - Auth: Auth{ - Mode: "squadXml", - SessionTTL: time.Hour, - AdminSteamIDs: adminIDs, - SteamAPIKey: "TESTKEY", - SquadXmlURL: srv.URL + "/squad.xml", - SquadXmlCacheTTL: 5 * time.Minute, - }, - }, - jwt: NewJWTManager("test-secret", time.Hour), - openIDCache: openid.NewSimpleDiscoveryCache(), - openIDNonceStore: openid.NewSimpleNonceStore(), - openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/" + steamID}, - steamAPIBaseURL: srv.URL, - } - hdlr.squadXml = newSquadXmlChecker(hdlr.setting.Auth.SquadXmlURL, hdlr.setting.Auth.SquadXmlCacheTTL) - - return hdlr, srv -} - -func TestSteamCallback_SquadXml_MemberGetsToken(t *testing.T) { - steamID := "76561198012345678" - hdlr, srv := newSquadXmlHandler(steamID, nil, []string{steamID, "76561198099999999"}) - defer srv.Close() - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_token=") - assert.NotContains(t, loc, "auth_error") - - u, err := url.Parse(loc) - require.NoError(t, err) - tokenValue := u.Query().Get("auth_token") - claims := hdlr.jwt.Claims(tokenValue) - require.NotNil(t, claims) - assert.Equal(t, "viewer", claims.Role) -} - -func TestSteamCallback_SquadXml_NonMemberGetsError(t *testing.T) { - steamID := "76561198012345678" - // Squad XML only contains a different user - hdlr, srv := newSquadXmlHandler(steamID, nil, []string{"76561198099999999"}) - defer srv.Close() - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_error=not_a_member") - assert.NotContains(t, loc, "auth_token=") -} - -func TestSteamCallback_SquadXml_AdminBypassesCheck(t *testing.T) { - steamID := "76561198012345678" - // Squad XML has NO members — if check runs, it would reject - hdlr, srv := newSquadXmlHandler(steamID, []string{steamID}, []string{}) - defer srv.Close() - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_token=") - assert.NotContains(t, loc, "auth_error") - - u, err := url.Parse(loc) - require.NoError(t, err) - tokenValue := u.Query().Get("auth_token") - claims := hdlr.jwt.Claims(tokenValue) - require.NotNil(t, claims) - assert.Equal(t, "admin", claims.Role) -} - // --- GetAuthConfig tests --- -func TestGetAuthConfig_ReturnsSteamGroupMode(t *testing.T) { - hdlr := Handler{ - setting: Setting{Auth: Auth{Mode: "steamGroup"}}, - } - - ctx := fuego.NewMockContextNoBody() - resp, err := hdlr.GetAuthConfig(ctx) - require.NoError(t, err) - assert.Equal(t, "steamGroup", resp.Mode) -} - func TestGetAuthConfig_ReturnsPublicMode(t *testing.T) { hdlr := Handler{ setting: Setting{Auth: Auth{Mode: "public"}}, @@ -1318,21 +768,3 @@ func TestGetAuthConfig_ReturnsEmptyWhenNotSet(t *testing.T) { assert.Equal(t, "", resp.Mode) } -func TestSteamCallback_SquadXml_FetchFailureRedirectsWithError(t *testing.T) { - steamID := "76561198012345678" - hdlr, srv := newSquadXmlHandler(steamID, nil, []string{steamID}) - defer srv.Close() - - // Replace the squad XML checker with one pointing to a dead server - hdlr.squadXml = newSquadXmlChecker("http://127.0.0.1:1/squad.xml", 5*time.Minute) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) - req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) - rec := httptest.NewRecorder() - - hdlr.SteamCallback(rec, req) - assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - - loc := rec.Header().Get("Location") - assert.Contains(t, loc, "auth_error=membership_check_failed") -} diff --git a/internal/server/setting.go b/internal/server/setting.go index 8d62a7b6..88f4f7ab 100644 --- a/internal/server/setting.go +++ b/internal/server/setting.go @@ -3,7 +3,6 @@ package server import ( "encoding/json" "fmt" - "log" "os" "slices" "strings" @@ -50,14 +49,11 @@ type Customize struct { } type Auth struct { - Mode string `json:"mode" yaml:"mode"` - SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` - AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` - SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` - Password string `json:"password" yaml:"password"` - SteamGroupID string `json:"steamGroupId" yaml:"steamGroupId"` - SquadXmlURL string `json:"squadXmlUrl" yaml:"squadXmlUrl"` - SquadXmlCacheTTL time.Duration `json:"squadXmlCacheTTL" yaml:"squadXmlCacheTTL"` + Mode string `json:"mode" yaml:"mode"` + SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` + AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` + SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` + Password string `json:"password" yaml:"password"` } type Streaming struct { @@ -106,12 +102,9 @@ func NewSetting() (setting Setting, err error) { viper.SetDefault("auth.steamApiKey", "") viper.SetDefault("auth.mode", "public") viper.SetDefault("auth.password", "") - viper.SetDefault("auth.steamGroupId", "") - viper.SetDefault("auth.squadXmlUrl", "") - viper.SetDefault("auth.squadXmlCacheTTL", "5m") // workaround for https://github.com/spf13/viper/issues/761 - envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey", "auth.mode", "auth.password", "auth.steamGroupId", "auth.squadXmlUrl", "auth.squadXmlCacheTTL"} + envKeys := []string{"listen", "prefixURL", "secret", "db", "markers", "ammo", "fonts", "maps", "data", "static", "customize.enabled", "customize.websiteurl", "customize.websitelogo", "customize.websitelogosize", "customize.disableKillCount", "customize.headertitle", "customize.headersubtitle", "conversion.enabled", "conversion.interval", "conversion.batchSize", "conversion.chunkSize", "conversion.retryFailed", "streaming.enabled", "streaming.pingInterval", "streaming.pingTimeout", "auth.sessionTTL", "auth.adminSteamIds", "auth.steamApiKey", "auth.mode", "auth.password"} for _, key := range envKeys { env := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) if err = viper.BindEnv(key, env); err != nil { @@ -161,7 +154,7 @@ func NewSetting() (setting Setting, err error) { } func validateAuthConfig(auth Auth) error { - validModes := []string{"public", "password", "steam", "steamGroup", "squadXml"} + validModes := []string{"public", "password", "steam", "steamAllowlist"} if !slices.Contains(validModes, auth.Mode) { return fmt.Errorf("auth.mode %q is not valid, must be one of: %s", auth.Mode, strings.Join(validModes, ", ")) } @@ -170,23 +163,6 @@ func validateAuthConfig(auth Auth) error { if auth.Password == "" { return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode) } - case "steamGroup": - if auth.SteamAPIKey == "" { - return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) - } - if auth.SteamGroupID == "" { - return fmt.Errorf("auth.mode %q requires auth.steamGroupId to be set", auth.Mode) - } - case "squadXml": - if auth.SteamAPIKey == "" { - return fmt.Errorf("auth.mode %q requires auth.steamApiKey to be set", auth.Mode) - } - if auth.SquadXmlURL == "" { - return fmt.Errorf("auth.mode %q requires auth.squadXmlUrl to be set", auth.Mode) - } - if auth.SquadXmlCacheTTL == 0 { - log.Printf("WARN: auth.squadXmlCacheTTL is 0, squad XML will be fetched on every login") - } } return nil } diff --git a/internal/server/setting_test.go b/internal/server/setting_test.go index 0f587ee8..95b2e6e7 100644 --- a/internal/server/setting_test.go +++ b/internal/server/setting_test.go @@ -493,16 +493,12 @@ func TestNewSetting_NoConfigFile(t *testing.T) { func TestValidateAuthConfig(t *testing.T) { t.Run("valid modes accepted", func(t *testing.T) { - for _, mode := range []string{"public", "steam"} { + for _, mode := range []string{"public", "steam", "steamAllowlist"} { err := validateAuthConfig(Auth{Mode: mode}) assert.NoError(t, err, "mode %q should be valid", mode) } err := validateAuthConfig(Auth{Mode: "password", Password: "secret"}) assert.NoError(t, err) - err = validateAuthConfig(Auth{Mode: "steamGroup", SteamAPIKey: "key", SteamGroupID: "123"}) - assert.NoError(t, err) - err = validateAuthConfig(Auth{Mode: "squadXml", SteamAPIKey: "key", SquadXmlURL: "https://example.com/squad.xml", SquadXmlCacheTTL: 5 * time.Minute}) - assert.NoError(t, err) }) t.Run("invalid mode returns error", func(t *testing.T) { @@ -518,38 +514,14 @@ func TestValidateAuthConfig(t *testing.T) { assert.Contains(t, err.Error(), "auth.password") }) - t.Run("steamGroup mode without steamApiKey", func(t *testing.T) { - err := validateAuthConfig(Auth{Mode: "steamGroup", SteamGroupID: "123"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "auth.steamApiKey") - }) - - t.Run("steamGroup mode without steamGroupId", func(t *testing.T) { - err := validateAuthConfig(Auth{Mode: "steamGroup", SteamAPIKey: "key"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "auth.steamGroupId") - }) - - t.Run("squadXml mode without steamApiKey", func(t *testing.T) { - err := validateAuthConfig(Auth{Mode: "squadXml", SquadXmlURL: "https://example.com/squad.xml"}) + t.Run("removed modes are rejected", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "steamGroup"}) assert.Error(t, err) - assert.Contains(t, err.Error(), "auth.steamApiKey") - }) + assert.Contains(t, err.Error(), "not valid") - t.Run("squadXml mode without squadXmlUrl", func(t *testing.T) { - err := validateAuthConfig(Auth{Mode: "squadXml", SteamAPIKey: "key"}) + err = validateAuthConfig(Auth{Mode: "squadXml"}) assert.Error(t, err) - assert.Contains(t, err.Error(), "auth.squadXmlUrl") - }) - - t.Run("squadXml mode with zero cacheTTL does not error", func(t *testing.T) { - err := validateAuthConfig(Auth{ - Mode: "squadXml", - SteamAPIKey: "key", - SquadXmlURL: "https://example.com/squad.xml", - SquadXmlCacheTTL: 0, - }) - assert.NoError(t, err) + assert.Contains(t, err.Error(), "not valid") }) } @@ -566,7 +538,6 @@ func TestNewSetting_AuthModeDefault(t *testing.T) { require.NoError(t, err) assert.Equal(t, "public", setting.Auth.Mode) - assert.Equal(t, 5*time.Minute, setting.Auth.SquadXmlCacheTTL) } func TestNewSetting_AuthModeInvalid(t *testing.T) { diff --git a/setting.json.example b/setting.json.example index fe1359e5..9d02e204 100644 --- a/setting.json.example +++ b/setting.json.example @@ -37,9 +37,6 @@ "sessionTTL": "24h", "adminSteamIds": [], "steamApiKey": "", - "password": "", - "steamGroupId": "", - "squadXmlUrl": "", - "squadXmlCacheTTL": "5m" + "password": "" } } From 6c07438ff9289d852e3e4f72508f8799a08028c2 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 16:32:52 +0100 Subject: [PATCH 13/16] feat(auth): add steamAllowlist mode with SQLite storage and admin API Add a new auth mode "steamAllowlist" where admins manage a list of allowed Steam IDs via API. Only allowed Steam IDs (plus admins) can log in when this mode is active. - Migration v11: creates steam_allowlist table - New allowlist.go with CRUD methods (Get, Add, Remove, IsOn) - Admin API endpoints: GET/PUT/DELETE /api/v1/auth/allowlist - SteamCallback checks allowlist before issuing JWT (admins bypass) - Tests for CRUD, handler endpoints, and callback behavior --- internal/server/allowlist.go | 49 +++++++ internal/server/handler.go | 3 + internal/server/handler_auth.go | 55 ++++++++ internal/server/handler_auth_test.go | 183 +++++++++++++++++++++++++++ internal/server/operation.go | 10 ++ internal/server/operation_test.go | 4 +- 6 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 internal/server/allowlist.go diff --git a/internal/server/allowlist.go b/internal/server/allowlist.go new file mode 100644 index 00000000..1a8da9c8 --- /dev/null +++ b/internal/server/allowlist.go @@ -0,0 +1,49 @@ +package server + +import "context" + +// GetAllowlist returns all Steam IDs in the allowlist. +func (r *RepoOperation) GetAllowlist(ctx context.Context) ([]string, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT steam_id FROM steam_allowlist ORDER BY steam_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// AddToAllowlist adds a Steam ID to the allowlist. +// The operation is idempotent — duplicate inserts are ignored. +func (r *RepoOperation) AddToAllowlist(ctx context.Context, steamID string) error { + _, err := r.db.ExecContext(ctx, + `INSERT OR IGNORE INTO steam_allowlist (steam_id) VALUES (?)`, + steamID) + return err +} + +// RemoveFromAllowlist removes a Steam ID from the allowlist. +func (r *RepoOperation) RemoveFromAllowlist(ctx context.Context, steamID string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM steam_allowlist WHERE steam_id = ?`, + steamID) + return err +} + +// IsOnAllowlist checks whether a Steam ID is on the allowlist. +func (r *RepoOperation) IsOnAllowlist(ctx context.Context, steamID string) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM steam_allowlist WHERE steam_id = ?`, + steamID).Scan(&count) + return count > 0, err +} diff --git a/internal/server/handler.go b/internal/server/handler.go index 2506f59b..e13e3478 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -179,6 +179,9 @@ func NewHandler( fuego.Post(admin, "/api/v1/operations/{id}/retry", hdlr.RetryConversion, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) fuego.Put(admin, "/api/v1/operations/{id}/marker-blacklist/{playerId}", hdlr.AddMarkerBlacklist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) fuego.Delete(admin, "/api/v1/operations/{id}/marker-blacklist/{playerId}", hdlr.RemoveMarkerBlacklist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) + fuego.Get(admin, "/api/v1/auth/allowlist", hdlr.GetAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) + fuego.Put(admin, "/api/v1/auth/allowlist/{steamId}", hdlr.AddToAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) + fuego.Delete(admin, "/api/v1/auth/allowlist/{steamId}", hdlr.RemoveFromAllowlist, fuego.OptionTags("Admin"), fuego.OptionSecurity(bearerAuth)) // MapTool (require admin JWT; SSE endpoint handles its own auth via query param) if hdlr.maptoolMgr != nil { diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index f212a080..a34561a5 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/go-fuego/fuego" "github.com/yohcop/openid-go" ) @@ -117,6 +118,20 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { role = "admin" } + // In steamAllowlist mode, check if the user is allowed (admins always bypass) + if h.setting.Auth.Mode == "steamAllowlist" && role != "admin" { + allowed, err := h.repoOperation.IsOnAllowlist(r.Context(), steamID) + if err != nil { + log.Printf("WARN: allowlist check failed for %s: %v", steamID, err) + h.authRedirect(w, r, "auth_error=steam_error") + return + } + if !allowed { + h.authRedirect(w, r, "auth_error=not_allowed") + return + } + } + // Fetch Steam profile data if API key is configured claimOpts := []ClaimOption{WithRole(role)} if h.setting.Auth.SteamAPIKey != "" { @@ -335,3 +350,43 @@ func randomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } + +// AllowlistResponse contains the Steam IDs on the allowlist. +type AllowlistResponse struct { + SteamIDs []string `json:"steamIds"` +} + +// GetAllowlist returns all Steam IDs on the allowlist. +func (h *Handler) GetAllowlist(c ContextNoBody) (AllowlistResponse, error) { + ids, err := h.repoOperation.GetAllowlist(c.Context()) + if err != nil { + return AllowlistResponse{}, err + } + return AllowlistResponse{SteamIDs: ids}, nil +} + +// AddToAllowlist adds a Steam ID to the allowlist. +func (h *Handler) AddToAllowlist(c ContextNoBody) (any, error) { + steamID := c.PathParam("steamId") + if steamID == "" { + return nil, fuego.BadRequestError{Detail: "steamId is required"} + } + if err := h.repoOperation.AddToAllowlist(c.Context(), steamID); err != nil { + return nil, err + } + c.SetStatus(http.StatusNoContent) + return nil, nil +} + +// RemoveFromAllowlist removes a Steam ID from the allowlist. +func (h *Handler) RemoveFromAllowlist(c ContextNoBody) (any, error) { + steamID := c.PathParam("steamId") + if steamID == "" { + return nil, fuego.BadRequestError{Detail: "steamId is required"} + } + if err := h.repoOperation.RemoveFromAllowlist(c.Context(), steamID); err != nil { + return nil, err + } + c.SetStatus(http.StatusNoContent) + return nil, nil +} diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 2f4cbb1d..7cd349c0 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -1,12 +1,14 @@ package server import ( + "context" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" + "path/filepath" "strings" "testing" "time" @@ -768,3 +770,184 @@ func TestGetAuthConfig_ReturnsEmptyWhenNotSet(t *testing.T) { assert.Equal(t, "", resp.Mode) } +// --- Allowlist CRUD tests --- + +func newAllowlistAuthHandler(t *testing.T, adminIDs []string) Handler { + t.Helper() + repo, err := NewRepoOperation(filepath.Join(t.TempDir(), "test.db")) + require.NoError(t, err) + return Handler{ + repoOperation: repo, + setting: Setting{ + Secret: "test-secret", + Auth: Auth{ + Mode: "steamAllowlist", + SessionTTL: time.Hour, + AdminSteamIDs: adminIDs, + }, + }, + jwt: NewJWTManager("test-secret", time.Hour), + openIDCache: openid.NewSimpleDiscoveryCache(), + openIDNonceStore: openid.NewSimpleNonceStore(), + openIDVerifier: mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"}, + } +} + +func TestAllowlistCRUD(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, nil) + ctx := context.Background() + + // Empty allowlist + ids, err := hdlr.repoOperation.GetAllowlist(ctx) + require.NoError(t, err) + assert.Empty(t, ids) + + // Add a Steam ID + err = hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678") + require.NoError(t, err) + + // Verify it's there + ids, err = hdlr.repoOperation.GetAllowlist(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"76561198012345678"}, ids) + + // Add again (idempotent) + err = hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678") + require.NoError(t, err) + + ids, err = hdlr.repoOperation.GetAllowlist(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"76561198012345678"}, ids) + + // IsOnAllowlist + on, err := hdlr.repoOperation.IsOnAllowlist(ctx, "76561198012345678") + require.NoError(t, err) + assert.True(t, on) + + on, err = hdlr.repoOperation.IsOnAllowlist(ctx, "76561198099999999") + require.NoError(t, err) + assert.False(t, on) + + // Remove + err = hdlr.repoOperation.RemoveFromAllowlist(ctx, "76561198012345678") + require.NoError(t, err) + + ids, err = hdlr.repoOperation.GetAllowlist(ctx) + require.NoError(t, err) + assert.Empty(t, ids) +} + +func TestGetAllowlist_Handler(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, nil) + ctx := context.Background() + + // Seed data + require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678")) + require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198099999999")) + + mockCtx := fuego.NewMockContextNoBody() + resp, err := hdlr.GetAllowlist(mockCtx) + require.NoError(t, err) + assert.Equal(t, []string{"76561198012345678", "76561198099999999"}, resp.SteamIDs) +} + +func TestAddToAllowlist_Handler(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, nil) + + mockCtx := fuego.NewMockContextNoBody() + mockCtx.PathParams = map[string]string{"steamId": "76561198012345678"} + + _, err := hdlr.AddToAllowlist(mockCtx) + require.NoError(t, err) + + // Verify it was added + ids, err := hdlr.repoOperation.GetAllowlist(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"76561198012345678"}, ids) +} + +func TestRemoveFromAllowlist_Handler(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, nil) + ctx := context.Background() + + // Seed data + require.NoError(t, hdlr.repoOperation.AddToAllowlist(ctx, "76561198012345678")) + + mockCtx := fuego.NewMockContextNoBody() + mockCtx.PathParams = map[string]string{"steamId": "76561198012345678"} + + _, err := hdlr.RemoveFromAllowlist(mockCtx) + require.NoError(t, err) + + // Verify it was removed + ids, err := hdlr.repoOperation.GetAllowlist(ctx) + require.NoError(t, err) + assert.Empty(t, ids) +} + +// --- SteamCallback allowlist mode tests --- + +func TestSteamCallback_AllowlistMode_AllowedUser(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, []string{"76561198099999999"}) // different admin + hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"} + + // Add the user to the allowlist + require.NoError(t, hdlr.repoOperation.AddToAllowlist(context.Background(), "76561198012345678")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "viewer", claims.Role) +} + +func TestSteamCallback_AllowlistMode_DeniedUser(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, []string{"76561198099999999"}) // different admin + hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"} + // Do NOT add to allowlist + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + assert.Contains(t, rec.Header().Get("Location"), "auth_error=not_allowed") +} + +func TestSteamCallback_AllowlistMode_AdminBypass(t *testing.T) { + hdlr := newAllowlistAuthHandler(t, []string{"76561198012345678"}) // same as the mock verifier + hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"} + // Do NOT add to allowlist — admin should bypass + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) + req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) + rec := httptest.NewRecorder() + + hdlr.SteamCallback(rec, req) + assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + assert.NotContains(t, loc, "auth_error") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "admin", claims.Role) +} + diff --git a/internal/server/operation.go b/internal/server/operation.go index 01aa7a6c..b80814df 100644 --- a/internal/server/operation.go +++ b/internal/server/operation.go @@ -232,6 +232,16 @@ func (r *RepoOperation) migration() (err error) { } } + if version < 11 { + if err = r.runMigration(11, + `CREATE TABLE IF NOT EXISTS steam_allowlist ( + steam_id TEXT NOT NULL PRIMARY KEY + )`, + ); err != nil { + return err + } + } + return nil } diff --git a/internal/server/operation_test.go b/internal/server/operation_test.go index 7ef7c015..889147d0 100644 --- a/internal/server/operation_test.go +++ b/internal/server/operation_test.go @@ -438,7 +438,7 @@ func TestMigrationRerun(t *testing.T) { var version int err = repo2.db.QueryRow("SELECT db FROM version ORDER BY db DESC LIMIT 1").Scan(&version) assert.NoError(t, err) - assert.Equal(t, 10, version) + assert.Equal(t, 11, version) } func TestMigrationV10NormalizeWorldName(t *testing.T) { @@ -461,7 +461,7 @@ func TestMigrationV10NormalizeWorldName(t *testing.T) { // Reset version so migration 10 runs again db, err := sql.Open("sqlite3", pathDB) require.NoError(t, err) - _, err = db.Exec(`DELETE FROM version WHERE db = 10`) + _, err = db.Exec(`DELETE FROM version WHERE db >= 10`) require.NoError(t, err) require.NoError(t, db.Close()) From 07bb73787a8ec061c90c5a360f3cefead01b5bcd Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 16:35:23 +0100 Subject: [PATCH 14/16] feat(ui): add steamAllowlist support to frontend - Add not_allowed error message for denied allowlist users - Add allowlist CRUD methods to ApiClient (admin use) - Add AuthBadge test for steamAllowlist mode - Remove obsolete steamGroup/squadXml error messages --- .../components/__tests__/AuthBadge.test.tsx | 8 ++++ ui/src/data/apiClient.ts | 37 +++++++++++++++++++ ui/src/hooks/__tests__/useAuth.test.tsx | 21 ++--------- ui/src/hooks/useAuth.tsx | 3 +- 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/ui/src/components/__tests__/AuthBadge.test.tsx b/ui/src/components/__tests__/AuthBadge.test.tsx index 26c8e4a6..21737a9e 100644 --- a/ui/src/components/__tests__/AuthBadge.test.tsx +++ b/ui/src/components/__tests__/AuthBadge.test.tsx @@ -136,6 +136,14 @@ describe("AuthBadge", () => { expect(queryByPlaceholderText("Password")).toBeNull(); }); + it("shows only Steam button in steamAllowlist mode", () => { + authState.authMode.mockReturnValue("steamAllowlist"); + + const { getByText, queryByPlaceholderText } = render(() => ); + expect(getByText("Sign in")).toBeDefined(); + expect(queryByPlaceholderText("Password")).toBeNull(); + }); + it("shows only Steam button in public mode", () => { authState.authMode.mockReturnValue("public"); diff --git a/ui/src/data/apiClient.ts b/ui/src/data/apiClient.ts index 4b2a28f0..3e57fd57 100644 --- a/ui/src/data/apiClient.ts +++ b/ui/src/data/apiClient.ts @@ -502,6 +502,43 @@ export class ApiClient { } } + // ─── Allowlist methods (admin) ─── + + async getAllowlist(): Promise { + const data = await this.fetchJsonAuth<{ steamIds: string[] }>( + `${this.baseUrl}/api/v1/auth/allowlist`, + ); + return data.steamIds; + } + + async addToAllowlist(steamId: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/auth/allowlist/${encodeURIComponent(steamId)}`, + { method: "PUT", headers: authHeaders() }, + ); + if (!response.ok) { + throw new ApiError( + `Add to allowlist failed: ${response.status} ${response.statusText}`, + response.status, + response.statusText, + ); + } + } + + async removeFromAllowlist(steamId: string): Promise { + const response = await fetch( + `${this.baseUrl}/api/v1/auth/allowlist/${encodeURIComponent(steamId)}`, + { method: "DELETE", headers: authHeaders() }, + ); + if (!response.ok) { + throw new ApiError( + `Remove from allowlist failed: ${response.status} ${response.statusText}`, + response.status, + response.statusText, + ); + } + } + // ─── MapTool methods ─── async getMapToolHealth(): Promise { diff --git a/ui/src/hooks/__tests__/useAuth.test.tsx b/ui/src/hooks/__tests__/useAuth.test.tsx index d9782945..5ec1d7ad 100644 --- a/ui/src/hooks/__tests__/useAuth.test.tsx +++ b/ui/src/hooks/__tests__/useAuth.test.tsx @@ -359,9 +359,9 @@ describe("useAuth", () => { expect(authRef.authenticated()).toBe(false); }); - it("maps not_a_member auth error", async () => { + it("maps not_allowed auth error", async () => { Object.defineProperty(window, "location", { - value: { ...window.location, search: "?auth_error=not_a_member", href: window.location.origin + "/?auth_error=not_a_member", pathname: "/" }, + value: { ...window.location, search: "?auth_error=not_allowed", href: window.location.origin + "/?auth_error=not_allowed", pathname: "/" }, writable: true, configurable: true, }); @@ -370,22 +370,7 @@ describe("useAuth", () => { renderAuth((a) => { authRef = a; }); await vi.waitFor(() => { - expect(authRef.authError()).toBe("You are not a member of this community. Contact an admin for access."); - }); - }); - - it("maps membership_check_failed auth error", async () => { - Object.defineProperty(window, "location", { - value: { ...window.location, search: "?auth_error=membership_check_failed", href: window.location.origin + "/?auth_error=membership_check_failed", pathname: "/" }, - writable: true, - configurable: true, - }); - - let authRef!: Auth; - renderAuth((a) => { authRef = a; }); - - await vi.waitFor(() => { - expect(authRef.authError()).toBe("Could not verify membership. Please try again later."); + expect(authRef.authError()).toBe("You are not on the allowlist. Contact an admin for access."); }); }); }); diff --git a/ui/src/hooks/useAuth.tsx b/ui/src/hooks/useAuth.tsx index ff284b1f..1042a148 100644 --- a/ui/src/hooks/useAuth.tsx +++ b/ui/src/hooks/useAuth.tsx @@ -19,8 +19,7 @@ export interface Auth { const AUTH_ERROR_MESSAGES: Record = { steam_error: "Steam login failed. Please try again.", - not_a_member: "You are not a member of this community. Contact an admin for access.", - membership_check_failed: "Could not verify membership. Please try again later.", + not_allowed: "You are not on the allowlist. Contact an admin for access.", }; const AuthContext = createContext(); From 308b6df8121a14d64ebbc50f6d0cfdcfb5b4612a Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 16:37:24 +0100 Subject: [PATCH 15/16] docs: update design doc to reflect steamAllowlist pivot --- .../plans/2026-03-08-access-control-design.md | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/docs/plans/2026-03-08-access-control-design.md b/docs/plans/2026-03-08-access-control-design.md index 0fdae4f7..f22dd2a8 100644 --- a/docs/plans/2026-03-08-access-control-design.md +++ b/docs/plans/2026-03-08-access-control-design.md @@ -13,8 +13,7 @@ Single `auth.mode` config value. Default `public` (current behavior). | `public` | No restrictions. Current behavior. | | `password` | Shared viewer password. | | `steam` | Any Steam account can view. | -| `steamGroup` | Steam login + Steam group membership required. | -| `squadXml` | Steam login + UID present in remote squad XML required. | +| `steamAllowlist` | Steam login + admin-managed allowlist of Steam IDs. | All non-public modes issue a JWT with `viewer` role on successful authentication. @@ -22,6 +21,7 @@ All non-public modes issue a JWT with `viewer` role on successful authentication ### Protected Endpoints - `/api/v1/operations*` — recording list, metadata, marker blacklist +- `/api/v1/worlds` — installed world metadata - `/data/*` — recording data files ### Always Public @@ -47,32 +47,30 @@ No gate. Optional Steam login for admin access. ### `password` 1. User enters shared password on login page -2. Backend validates password against `auth.password` config -3. JWT issued with `viewer` role +2. Backend validates password against `auth.password` config (timing-safe comparison) +3. JWT issued with `viewer` role, subject `password` ### `steam` 1. User clicks Steam login button 2. Standard Steam OpenID flow 3. JWT issued with `viewer` role (or `admin` if in `adminSteamIds`) -### `steamGroup` +### `steamAllowlist` 1. User clicks Steam login button 2. Steam OpenID flow completes, Steam ID obtained -3. Backend checks group membership via Steam Web API (`steamApiKey` + `steamGroupId`) -4. **Member** → JWT issued with `viewer` role -5. **Not a member** → no token issued, error message, redirect to login +3. Backend checks if Steam ID is in `steam_allowlist` SQLite table +4. **Admins bypass** — users in `adminSteamIds` always get `admin` role regardless of allowlist +5. **On allowlist** → JWT issued with `viewer` role +6. **Not on allowlist** → no token issued, redirect with `auth_error=not_allowed` -### `squadXml` -1. User clicks Steam login button -2. Steam OpenID flow completes, Steam ID obtained -3. Backend fetches squad XML from `squadXmlUrl` (cached per `squadXmlCacheTTL`) -4. Checks if Steam UID is present in the XML -5. **Found** → JWT issued with `viewer` role -6. **Not found** → no token issued, error message, redirect to login +Admins manage the allowlist via API: +- `GET /api/v1/auth/allowlist` — list all allowed Steam IDs +- `PUT /api/v1/auth/allowlist/{steamId}` — add (idempotent) +- `DELETE /api/v1/auth/allowlist/{steamId}` — remove ## Admin Bypass -Users whose Steam ID is in `adminSteamIds` always pass the gate regardless of mode. This prevents admin lockout (e.g. admin not in Steam group or squad XML). +Users whose Steam ID is in `adminSteamIds` always pass the gate regardless of mode. This prevents admin lockout. ## Login UI @@ -81,10 +79,7 @@ Users whose Steam ID is in `adminSteamIds` always pass the gate regardless of mo | `public` | — | Steam button (admin) | | `password` | Password field + submit | Steam button (admin) | | `steam` | Steam button | — | -| `steamGroup` | Steam button | — | -| `squadXml` | Steam button | — | - -Visual lock icon or indicator when instance is restricted (non-public mode). +| `steamAllowlist` | Steam button | — | ## Configuration @@ -93,11 +88,8 @@ Visual lock icon or indicator when instance is restricted (non-public mode). "mode": "public", "sessionTTL": "24h", "adminSteamIds": ["76561198000074241"], - "steamApiKey": "...", - "password": "viewer-password-here", - "steamGroupId": "103582791460XXXXX", - "squadXmlUrl": "https://example.com/squad.xml", - "squadXmlCacheTTL": "5m" + "steamApiKey": "", + "password": "" } ``` @@ -105,15 +97,24 @@ Fields only relevant to the active mode are ignored. ## Startup Validation -Server validates on start that required config values for the active mode are present. Missing required values are fatal errors. Optional warnings for edge cases. +Server validates on start that required config values for the active mode are present. + +| Mode | Required | +|------|----------| +| `public` | — | +| `password` | `password` | +| `steam` | — | +| `steamAllowlist` | — | -| Mode | Required | Warnings | -|------|----------|----------| -| `public` | — | — | -| `password` | `password` | — | -| `steam` | — | — | -| `steamGroup` | `steamApiKey`, `steamGroupId` | — | -| `squadXml` | `steamApiKey`, `squadXmlUrl` | `squadXmlCacheTTL=0` → "caching disabled, fetching on every login" | +## Storage + +The `steam_allowlist` table (migration v11) stores allowed Steam IDs: + +```sql +CREATE TABLE steam_allowlist ( + steam_id TEXT NOT NULL PRIMARY KEY +); +``` ## Future Compatibility From 88746b46b1a9ef3e01d4aec089dc491299232621 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Sun, 8 Mar 2026 20:38:02 +0100 Subject: [PATCH 16/16] fix(test): close DB connection in allowlist test helper --- internal/server/handler_auth_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 7cd349c0..3dd0b498 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -776,6 +776,7 @@ func newAllowlistAuthHandler(t *testing.T, adminIDs []string) Handler { t.Helper() repo, err := NewRepoOperation(filepath.Join(t.TempDir(), "test.db")) require.NoError(t, err) + t.Cleanup(func() { repo.db.Close() }) return Handler{ repoOperation: repo, setting: Setting{