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..f22dd2a8 --- /dev/null +++ b/docs/plans/2026-03-08-access-control-design.md @@ -0,0 +1,121 @@ +# 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. | +| `steamAllowlist` | Steam login + admin-managed allowlist of Steam IDs. | + +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 +- `/api/v1/worlds` — installed world metadata +- `/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 (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`) + +### `steamAllowlist` +1. User clicks Steam login button +2. Steam OpenID flow completes, Steam ID obtained +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` + +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. + +## Login UI + +| Mode | Primary Action | Secondary Action | +|------|---------------|-----------------| +| `public` | — | Steam button (admin) | +| `password` | Password field + submit | Steam button (admin) | +| `steam` | Steam button | — | +| `steamAllowlist` | Steam button | — | + +## Configuration + +```json +"auth": { + "mode": "public", + "sessionTTL": "24h", + "adminSteamIds": ["76561198000074241"], + "steamApiKey": "", + "password": "" +} +``` + +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. + +| Mode | Required | +|------|----------| +| `public` | — | +| `password` | `password` | +| `steam` | — | +| `steamAllowlist` | — | + +## 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 + +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. 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. 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 6c535012..e13e3478 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)) @@ -160,8 +164,10 @@ 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")) 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)) @@ -173,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 93221b23..a34561a5 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" "fmt" @@ -12,6 +13,7 @@ import ( "strings" "time" + "github.com/go-fuego/fuego" "github.com/yohcop/openid-go" ) @@ -116,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 != "" { @@ -154,6 +170,36 @@ 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) { + if h.setting.Auth.Mode != "password" { + http.Error(w, "password login not enabled", http.StatusNotFound) + return + } + + 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 == "" || subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.setting.Auth.Password)) != 1 { + 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"` @@ -179,12 +225,41 @@ 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) 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) { @@ -275,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 4a7eaef1..3dd0b498 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" @@ -541,6 +543,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")) @@ -556,3 +624,331 @@ 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{ + Mode: "password", + 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) +} + +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) +} + +// --- GetAuthConfig tests --- + +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) +} + +// --- 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) + t.Cleanup(func() { repo.db.Close() }) + 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/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)) 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()) diff --git a/internal/server/setting.go b/internal/server/setting.go index 6d682c5c..88f4f7ab 100644 --- a/internal/server/setting.go +++ b/internal/server/setting.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strings" "time" @@ -48,9 +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"` } type Streaming struct { @@ -97,10 +100,11 @@ 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", "") // 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"} for _, key := range envKeys { env := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) if err = viper.BindEnv(key, env); err != nil { @@ -123,6 +127,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 +153,20 @@ func NewSetting() (setting Setting, err error) { return } +func validateAuthConfig(auth Auth) error { + 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, ", ")) + } + switch auth.Mode { + case "password": + if auth.Password == "" { + return fmt.Errorf("auth.mode %q requires auth.password to be set", auth.Mode) + } + } + 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..95b2e6e7 100644 --- a/internal/server/setting_test.go +++ b/internal/server/setting_test.go @@ -490,3 +490,88 @@ 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", "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) + }) + + 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("removed modes are rejected", func(t *testing.T) { + err := validateAuthConfig(Auth{Mode: "steamGroup"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not valid") + + err = validateAuthConfig(Auth{Mode: "squadXml"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not valid") + }) +} + +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) +} + +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..9d02e204 100644 --- a/setting.json.example +++ b/setting.json.example @@ -33,8 +33,10 @@ "pingTimeout": "10s" }, "auth": { + "mode": "public", "sessionTTL": "24h", "adminSteamIds": [], - "steamApiKey": "" + "steamApiKey": "", + "password": "" } } 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..21737a9e 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,61 @@ 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 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"); + + 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/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..3e57fd57 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(), @@ -470,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 { @@ -590,7 +659,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 +680,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}`, diff --git a/ui/src/hooks/__tests__/useAuth.test.tsx b/ui/src/hooks/__tests__/useAuth.test.tsx index 723283b3..5ec1d7ad 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,87 @@ 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_allowed auth error", async () => { + Object.defineProperty(window, "location", { + value: { ...window.location, search: "?auth_error=not_allowed", href: window.location.origin + "/?auth_error=not_allowed", pathname: "/" }, + writable: true, + configurable: true, + }); + + let authRef!: Auth; + renderAuth((a) => { authRef = a; }); + + await vi.waitFor(() => { + 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 15b0c1d7..1042a148 100644 --- a/ui/src/hooks/useAuth.tsx +++ b/ui/src/hooks/useAuth.tsx @@ -10,13 +10,16 @@ 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_allowed: "You are not on the allowlist. Contact an admin for access.", }; const AuthContext = createContext(); @@ -32,9 +35,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 +90,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 +125,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