From 7e7f1b637125eacfc62d05e2923c4b6e71985818 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 5 Mar 2026 19:53:57 +0100 Subject: [PATCH 1/8] feat(auth): add Role field to JWT claims --- internal/server/jwt.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/server/jwt.go b/internal/server/jwt.go index dc8746d8b..576fb3b6e 100644 --- a/internal/server/jwt.go +++ b/internal/server/jwt.go @@ -18,6 +18,7 @@ type SteamClaims struct { jwt.RegisteredClaims SteamName string `json:"steam_name,omitempty"` SteamAvatar string `json:"steam_avatar,omitempty"` + Role string `json:"role,omitempty"` } func NewJWTManager(secret string, ttl time.Duration) *JWTManager { @@ -55,6 +56,13 @@ func WithSteamProfile(name, avatar string) ClaimOption { } } +// WithRole sets the user's role in the token. +func WithRole(role string) ClaimOption { + return func(c *SteamClaims) { + c.Role = role + } +} + // Validate parses the token and checks signature and expiry. func (m *JWTManager) Validate(tokenString string) error { _, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { From 65ee8a28cbb370fa08f68007c5b5b048b4b22a64 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 5 Mar 2026 19:56:35 +0100 Subject: [PATCH 2/8] refactor(auth): rename config section from admin to auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: Config key admin.* renamed to auth.*. Env vars: OCAP_ADMIN_* → OCAP_AUTH_*. Field allowedSteamIds → adminSteamIds. --- internal/server/handler.go | 2 +- internal/server/handler_admin_test.go | 2 +- internal/server/handler_auth.go | 6 +++--- internal/server/handler_auth_test.go | 12 ++++++------ internal/server/setting.go | 20 ++++++++++---------- internal/server/setting_test.go | 24 ++++++++++++------------ setting.json.example | 4 ++-- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 418a49907..ffa122586 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -127,7 +127,7 @@ func NewHandler( opt(&hdlr) } - hdlr.jwt = NewJWTManager(setting.Secret, setting.Admin.SessionTTL) + hdlr.jwt = NewJWTManager(setting.Secret, setting.Auth.SessionTTL) hdlr.openIDCache = openid.NewSimpleDiscoveryCache() hdlr.openIDNonceStore = openid.NewSimpleNonceStore() hdlr.openIDVerifier = defaultOpenIDVerifier{} diff --git a/internal/server/handler_admin_test.go b/internal/server/handler_admin_test.go index 7ced42313..99c7c3346 100644 --- a/internal/server/handler_admin_test.go +++ b/internal/server/handler_admin_test.go @@ -323,7 +323,7 @@ func TestAdminFlow_LoginEditDelete(t *testing.T) { setting := Setting{ Secret: "test-secret", Data: dir, - Admin: Admin{SessionTTL: time.Hour}, + Auth: Auth{SessionTTL: time.Hour}, } jwtMgr := NewJWTManager("test-secret", time.Hour) diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index 880331c62..e878fb22a 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -111,19 +111,19 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { } // Check allowlist - if !isSteamIDAllowed(steamID, h.setting.Admin.AllowedSteamIDs) { + if !isSteamIDAllowed(steamID, h.setting.Auth.AdminSteamIDs) { h.authRedirect(w, r, "auth_error=steam_denied") return } // Fetch Steam profile data if API key is configured var claimOpts []ClaimOption - if h.setting.Admin.SteamAPIKey != "" { + if h.setting.Auth.SteamAPIKey != "" { baseURL := steamAPIBaseURL if h.steamAPIBaseURL != "" { baseURL = h.steamAPIBaseURL } - if name, avatar, err := fetchSteamProfileFrom(baseURL, steamID, h.setting.Admin.SteamAPIKey); err == nil { + if name, avatar, err := fetchSteamProfileFrom(baseURL, steamID, h.setting.Auth.SteamAPIKey); err == nil { claimOpts = append(claimOpts, WithSteamProfile(name, avatar)) } else { log.Printf("WARN: failed to fetch Steam profile for %s: %v", steamID, err) diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index 8ecd14a98..cda7e6f27 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -28,13 +28,13 @@ func (m mockVerifier) Verify(string, openid.DiscoveryCache, openid.NonceStore) ( return m.claimedID, m.err } -func newSteamAuthHandler(allowedIDs []string) Handler { +func newSteamAuthHandler(adminIDs []string) Handler { return Handler{ setting: Setting{ Secret: "test-secret", - Admin: Admin{ - SessionTTL: time.Hour, - AllowedSteamIDs: allowedIDs, + Auth: Auth{ + SessionTTL: time.Hour, + AdminSteamIDs: adminIDs, }, }, jwt: NewJWTManager("test-secret", time.Hour), @@ -428,7 +428,7 @@ func TestSteamCallback_SteamAPIError(t *testing.T) { defer srv.Close() hdlr := newSteamAuthHandler([]string{"76561198012345678"}) - hdlr.setting.Admin.SteamAPIKey = "TESTKEY" + hdlr.setting.Auth.SteamAPIKey = "TESTKEY" hdlr.steamAPIBaseURL = srv.URL req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) @@ -464,7 +464,7 @@ func TestSteamCallback_WithSteamAPIKey(t *testing.T) { defer srv.Close() hdlr := newSteamAuthHandler([]string{"76561198012345678"}) - hdlr.setting.Admin.SteamAPIKey = "TESTKEY" + hdlr.setting.Auth.SteamAPIKey = "TESTKEY" hdlr.steamAPIBaseURL = srv.URL req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) diff --git a/internal/server/setting.go b/internal/server/setting.go index 949290aab..6d682c5cc 100644 --- a/internal/server/setting.go +++ b/internal/server/setting.go @@ -25,7 +25,7 @@ type Setting struct { Customize Customize `json:"customize" yaml:"customize"` Conversion Conversion `json:"conversion" yaml:"conversion"` Streaming Streaming `json:"streaming" yaml:"streaming"` - Admin Admin `json:"admin" yaml:"admin"` + Auth Auth `json:"auth" yaml:"auth"` } type Conversion struct { @@ -47,10 +47,10 @@ type Customize struct { CSSOverrides map[string]string `json:"cssOverrides,omitempty" yaml:"cssOverrides"` } -type Admin struct { - SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` - AllowedSteamIDs []string `json:"allowedSteamIds" yaml:"allowedSteamIds"` - SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` +type Auth struct { + SessionTTL time.Duration `json:"sessionTTL" yaml:"sessionTTL"` + AdminSteamIDs []string `json:"adminSteamIds" yaml:"adminSteamIds"` + SteamAPIKey string `json:"steamApiKey" yaml:"steamApiKey"` } type Streaming struct { @@ -94,13 +94,13 @@ func NewSetting() (setting Setting, err error) { viper.SetDefault("streaming.enabled", false) viper.SetDefault("streaming.pingInterval", "30s") viper.SetDefault("streaming.pingTimeout", "10s") - viper.SetDefault("admin.sessionTTL", "24h") - viper.SetDefault("admin.allowedSteamIds", []string{}) - viper.SetDefault("admin.steamApiKey", "") + viper.SetDefault("auth.sessionTTL", "24h") + viper.SetDefault("auth.adminSteamIds", []string{}) + viper.SetDefault("auth.steamApiKey", "") // 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", "admin.sessionTTL", "admin.allowedSteamIds", "admin.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"} for _, key := range envKeys { env := strings.ToUpper(strings.ReplaceAll(key, ".", "_")) if err = viper.BindEnv(key, env); err != nil { @@ -121,7 +121,7 @@ func NewSetting() (setting Setting, err error) { // Viper doesn't split comma-separated env var strings into slices, // so a value like "id1,id2" ends up as ["id1,id2"]. Expand it. - setting.Admin.AllowedSteamIDs = splitCSV(setting.Admin.AllowedSteamIDs) + setting.Auth.AdminSteamIDs = splitCSV(setting.Auth.AdminSteamIDs) // Viper can't unmarshal a JSON string env var into map[string]string, // so parse OCAP_CUSTOMIZE_CSSOVERRIDES manually if set. Env var takes diff --git a/internal/server/setting_test.go b/internal/server/setting_test.go index f9975f966..1740dda75 100644 --- a/internal/server/setting_test.go +++ b/internal/server/setting_test.go @@ -379,14 +379,14 @@ func TestNewSetting_EnvVars(t *testing.T) { }) } -func TestSetting_AdminSessionTTL(t *testing.T) { +func TestSetting_AuthSessionTTL(t *testing.T) { defer viper.Reset() dir := t.TempDir() configPath := filepath.Join(dir, "setting.json") err := os.WriteFile(configPath, []byte(`{ "secret": "test-secret-value", - "admin": { + "auth": { "sessionTTL": "2h" } }`), 0644) @@ -397,10 +397,10 @@ func TestSetting_AdminSessionTTL(t *testing.T) { setting, err := NewSetting() require.NoError(t, err) - assert.Equal(t, 2*time.Hour, setting.Admin.SessionTTL) + assert.Equal(t, 2*time.Hour, setting.Auth.SessionTTL) } -func TestSetting_AdminSessionTTL_Default(t *testing.T) { +func TestSetting_AuthSessionTTL_Default(t *testing.T) { defer viper.Reset() dir := t.TempDir() @@ -413,18 +413,18 @@ func TestSetting_AdminSessionTTL_Default(t *testing.T) { setting, err := NewSetting() require.NoError(t, err) - assert.Equal(t, 24*time.Hour, setting.Admin.SessionTTL) + assert.Equal(t, 24*time.Hour, setting.Auth.SessionTTL) } -func TestSetting_AdminAllowedSteamIDs(t *testing.T) { +func TestSetting_AuthAdminSteamIDs(t *testing.T) { defer viper.Reset() dir := t.TempDir() configPath := filepath.Join(dir, "setting.json") err := os.WriteFile(configPath, []byte(`{ "secret": "test-secret-value", - "admin": { - "allowedSteamIds": ["76561198012345678", "76561198087654321"] + "auth": { + "adminSteamIds": ["76561198012345678", "76561198087654321"] } }`), 0644) require.NoError(t, err) @@ -434,17 +434,17 @@ func TestSetting_AdminAllowedSteamIDs(t *testing.T) { setting, err := NewSetting() require.NoError(t, err) - assert.Equal(t, []string{"76561198012345678", "76561198087654321"}, setting.Admin.AllowedSteamIDs) + assert.Equal(t, []string{"76561198012345678", "76561198087654321"}, setting.Auth.AdminSteamIDs) } -func TestSetting_AdminSteamAPIKey(t *testing.T) { +func TestSetting_AuthSteamAPIKey(t *testing.T) { defer viper.Reset() dir := t.TempDir() configPath := filepath.Join(dir, "setting.json") err := os.WriteFile(configPath, []byte(`{ "secret": "test-secret-value", - "admin": { + "auth": { "steamApiKey": "ABCDEF0123456789" } }`), 0644) @@ -455,7 +455,7 @@ func TestSetting_AdminSteamAPIKey(t *testing.T) { setting, err := NewSetting() require.NoError(t, err) - assert.Equal(t, "ABCDEF0123456789", setting.Admin.SteamAPIKey) + assert.Equal(t, "ABCDEF0123456789", setting.Auth.SteamAPIKey) } func TestSplitCSV(t *testing.T) { diff --git a/setting.json.example b/setting.json.example index a3b2ad899..708b74bac 100644 --- a/setting.json.example +++ b/setting.json.example @@ -32,9 +32,9 @@ "pingInterval": "30s", "pingTimeout": "10s" }, - "admin": { + "auth": { "sessionTTL": "24h", - "allowedSteamIds": [], + "adminSteamIds": [], "steamApiKey": "" } } From 6a9245041fb25cf0446d0a6e354f3456bec8e313 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 5 Mar 2026 20:01:46 +0100 Subject: [PATCH 3/8] feat(auth): open Steam login to all users with role assignment Non-admin Steam users now receive a viewer JWT instead of being rejected. requireAdmin checks role claim. Removes isSteamIDAllowed. --- internal/server/handler_admin_test.go | 4 +- internal/server/handler_auth.go | 24 +++--- internal/server/handler_auth_test.go | 117 ++++++++++++++++++++------ 3 files changed, 108 insertions(+), 37 deletions(-) diff --git a/internal/server/handler_admin_test.go b/internal/server/handler_admin_test.go index 99c7c3346..ef42636ad 100644 --- a/internal/server/handler_admin_test.go +++ b/internal/server/handler_admin_test.go @@ -356,8 +356,8 @@ func TestAdminFlow_LoginEditDelete(t *testing.T) { client := &http.Client{} opID := fmt.Sprintf("%d", op.ID) - // Create a JWT directly (simulates successful Steam login) - authToken, err := jwtMgr.Create("76561198012345678") + // Create a JWT directly (simulates successful Steam login with admin role) + authToken, err := jwtMgr.Create("76561198012345678", WithRole("admin")) require.NoError(t, err) // Step 1: Check auth status — verify authenticated:true diff --git a/internal/server/handler_auth.go b/internal/server/handler_auth.go index e878fb22a..812de8e78 100644 --- a/internal/server/handler_auth.go +++ b/internal/server/handler_auth.go @@ -110,14 +110,14 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) { return } - // Check allowlist - if !isSteamIDAllowed(steamID, h.setting.Auth.AdminSteamIDs) { - h.authRedirect(w, r, "auth_error=steam_denied") - return + // Determine role based on admin allowlist + role := "viewer" + if slices.Contains(h.setting.Auth.AdminSteamIDs, steamID) { + role = "admin" } // Fetch Steam profile data if API key is configured - var claimOpts []ClaimOption + claimOpts := []ClaimOption{WithRole(role)} if h.setting.Auth.SteamAPIKey != "" { baseURL := steamAPIBaseURL if h.steamAPIBaseURL != "" { @@ -157,6 +157,7 @@ func (h *Handler) authRedirect(w http.ResponseWriter, r *http.Request, query str // MeResponse describes the authentication status returned by GetMe. type MeResponse struct { Authenticated bool `json:"authenticated"` + Role string `json:"role,omitempty"` SteamID string `json:"steamId,omitempty"` SteamName string `json:"steamName,omitempty"` SteamAvatar string `json:"steamAvatar,omitempty"` @@ -170,6 +171,7 @@ func (h *Handler) GetMe(c ContextNoBody) (MeResponse, error) { } resp := MeResponse{Authenticated: true} if claims := h.jwt.Claims(token); claims != nil { + resp.Role = claims.Role resp.SteamID = claims.Subject resp.SteamName = claims.SteamName resp.SteamAvatar = claims.SteamAvatar @@ -183,7 +185,7 @@ func (h *Handler) Logout(c ContextNoBody) (any, error) { return nil, nil } -// requireAdmin is middleware that checks for a valid JWT Bearer token. +// 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) { token := bearerToken(r) @@ -191,6 +193,11 @@ func (h *Handler) requireAdmin(next http.Handler) http.Handler { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + claims := h.jwt.Claims(token) + if claims == nil || claims.Role != "admin" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } next.ServeHTTP(w, r) }) } @@ -204,11 +211,6 @@ func extractSteamID(claimedID string) string { return "" } -// isSteamIDAllowed checks if a Steam ID is in the allowlist. -func isSteamIDAllowed(steamID string, allowed []string) bool { - return slices.Contains(allowed, steamID) -} - // requestHost returns the original client-facing host, respecting X-Forwarded-Host // from reverse proxies (including Vite dev proxy). func requestHost(r *http.Request) string { diff --git a/internal/server/handler_auth_test.go b/internal/server/handler_auth_test.go index cda7e6f27..4a7eaef17 100644 --- a/internal/server/handler_auth_test.go +++ b/internal/server/handler_auth_test.go @@ -103,9 +103,8 @@ func TestSteamCallback_NonceMismatch(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } -func TestSteamCallback_UnauthorizedSteamID(t *testing.T) { - hdlr := newSteamAuthHandler([]string{"76561198099999999"}) // different ID - hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"} +func TestSteamCallback_AdminGetsAdminRole(t *testing.T) { + hdlr := newSteamAuthHandler([]string{"76561198012345678"}) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) @@ -113,11 +112,23 @@ func TestSteamCallback_UnauthorizedSteamID(t *testing.T) { hdlr.SteamCallback(rec, req) assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - assert.Contains(t, rec.Header().Get("Location"), "auth_error=steam_denied") + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + + 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, "76561198012345678", claims.Subject) + assert.Equal(t, "admin", claims.Role) } -func TestSteamCallback_Success(t *testing.T) { - hdlr := newSteamAuthHandler([]string{"76561198012345678"}) +func TestSteamCallback_NonAdminGetsViewerRole(t *testing.T) { + hdlr := newSteamAuthHandler([]string{"76561198099999999"}) // different ID than the mock verifier + hdlr.openIDVerifier = mockVerifier{claimedID: "https://steamcommunity.com/openid/id/76561198012345678"} req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) @@ -126,17 +137,18 @@ func TestSteamCallback_Success(t *testing.T) { hdlr.SteamCallback(rec, req) assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - // Token should be in the redirect URL query param 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") - assert.NotEmpty(t, tokenValue) - assert.NoError(t, hdlr.jwt.Validate(tokenValue)) - assert.Equal(t, "76561198012345678", hdlr.jwt.Subject(tokenValue)) + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "76561198012345678", claims.Subject) + assert.Equal(t, "viewer", claims.Role) } func TestGetMe_WithSteamID(t *testing.T) { @@ -193,17 +205,6 @@ func TestLogout(t *testing.T) { require.NoError(t, err) } -func TestIsSteamIDAllowed(t *testing.T) { - allowed := []string{"76561198012345678", "76561198087654321"} - - assert.True(t, isSteamIDAllowed("76561198012345678", allowed)) - assert.True(t, isSteamIDAllowed("76561198087654321", allowed)) - assert.False(t, isSteamIDAllowed("76561198000000000", allowed)) - assert.False(t, isSteamIDAllowed("", allowed)) - assert.False(t, isSteamIDAllowed("76561198012345678", nil)) - assert.False(t, isSteamIDAllowed("76561198012345678", []string{})) -} - func TestExtractSteamID(t *testing.T) { assert.Equal(t, "76561198012345678", extractSteamID("https://steamcommunity.com/openid/id/76561198012345678")) assert.Equal(t, "", extractSteamID("https://example.com/openid/id/76561198012345678")) @@ -409,8 +410,8 @@ func TestRandomHex(t *testing.T) { assert.NotEqual(t, result, result2) } -func TestSteamCallback_AllowedEmptyList(t *testing.T) { - hdlr := newSteamAuthHandler([]string{}) // empty allowed list +func TestSteamCallback_EmptyAdminList_GetsViewerRole(t *testing.T) { + hdlr := newSteamAuthHandler([]string{}) // empty admin list req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/steam/callback?nonce=abc", nil) req.AddCookie(&http.Cookie{Name: cookieNonce, Value: "abc"}) @@ -418,7 +419,17 @@ func TestSteamCallback_AllowedEmptyList(t *testing.T) { hdlr.SteamCallback(rec, req) assert.Equal(t, http.StatusTemporaryRedirect, rec.Code) - assert.Contains(t, rec.Header().Get("Location"), "auth_error=steam_denied") + + loc := rec.Header().Get("Location") + assert.Contains(t, loc, "auth_token=") + + 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_SteamAPIError(t *testing.T) { @@ -441,6 +452,13 @@ func TestSteamCallback_SteamAPIError(t *testing.T) { // Should still get auth_token (just no profile data) loc := rec.Header().Get("Location") assert.Contains(t, loc, "auth_token=") + + u, err := url.Parse(loc) + require.NoError(t, err) + tokenValue := u.Query().Get("auth_token") + claims := hdlr.jwt.Claims(tokenValue) + require.NotNil(t, claims) + assert.Equal(t, "admin", claims.Role) } func TestSteamCallback_WithSteamAPIKey(t *testing.T) { @@ -484,6 +502,57 @@ func TestSteamCallback_WithSteamAPIKey(t *testing.T) { claims := hdlr.jwt.Claims(tokenValue) require.NotNil(t, claims) assert.Equal(t, "76561198012345678", claims.Subject) + assert.Equal(t, "admin", claims.Role) assert.Equal(t, "TestPlayer", claims.SteamName) assert.Equal(t, "https://avatars.steamstatic.com/abc.jpg", claims.SteamAvatar) } + +func TestRequireAdmin_RejectsViewerRole(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + 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.requireAdmin(next).ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestRequireAdmin_AllowsAdminRole(t *testing.T) { + hdlr := newSteamAuthHandler(nil) + 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.requireAdmin(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")) + require.NoError(t, err) + + ctx := fuego.NewMockContextNoBody() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + ctx.SetRequest(req) + + resp, err := hdlr.GetMe(ctx) + require.NoError(t, err) + assert.True(t, resp.Authenticated) + assert.Equal(t, "viewer", resp.Role) +} From c1a12f2304298ad17ad58e316cd74a0064df1435 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 5 Mar 2026 20:04:22 +0100 Subject: [PATCH 4/8] feat(ui): add role and isAdmin to auth hook AuthState now includes role from /me endpoint. useAuth exposes role() and isAdmin() signals. Removes steam_denied error message. --- ui/src/data/apiClient.ts | 1 + ui/src/hooks/__tests__/useAuth.test.tsx | 59 ++++++++++++++++++++++++- ui/src/hooks/useAuth.tsx | 11 +++-- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/ui/src/data/apiClient.ts b/ui/src/data/apiClient.ts index 382d5f5e7..85a5cca39 100644 --- a/ui/src/data/apiClient.ts +++ b/ui/src/data/apiClient.ts @@ -21,6 +21,7 @@ export interface BuildInfo { export interface AuthState { authenticated: boolean; + role?: string; steamId?: string; steamName?: string; steamAvatar?: string; diff --git a/ui/src/hooks/__tests__/useAuth.test.tsx b/ui/src/hooks/__tests__/useAuth.test.tsx index 4d435b76a..723283b31 100644 --- a/ui/src/hooks/__tests__/useAuth.test.tsx +++ b/ui/src/hooks/__tests__/useAuth.test.tsx @@ -154,7 +154,7 @@ describe("useAuth", () => { it("reads auth_error from URL and sets authError signal", async () => { Object.defineProperty(window, "location", { - value: { ...window.location, search: "?auth_error=steam_denied", href: window.location.origin + "/?auth_error=steam_denied", pathname: "/" }, + value: { ...window.location, search: "?auth_error=steam_error", href: window.location.origin + "/?auth_error=steam_error", pathname: "/" }, writable: true, configurable: true, }); @@ -164,7 +164,7 @@ describe("useAuth", () => { await vi.waitFor(() => { expect(authRef).toBeDefined(); - expect(authRef.authError()).toBe("Your Steam account is not authorized for admin access."); + expect(authRef.authError()).toBe("Steam login failed. Please try again."); }); }); @@ -229,4 +229,59 @@ describe("useAuth", () => { expect(authRef.steamAvatar()).toBeNull(); expect(mockLogout).toHaveBeenCalledOnce(); }); + + it("exposes role and isAdmin from getMe", async () => { + setAuthToken("stored-jwt"); + mockGetMe.mockResolvedValue({ + authenticated: true, + role: "admin", + steamId: "76561198012345678", + }); + + let authRef!: Auth; + renderAuth((a) => { authRef = a; }); + + await vi.waitFor(() => { + expect(authRef.role()).toBe("admin"); + expect(authRef.isAdmin()).toBe(true); + }); + }); + + it("viewer role sets isAdmin to false", async () => { + setAuthToken("stored-jwt"); + mockGetMe.mockResolvedValue({ + authenticated: true, + role: "viewer", + steamId: "76561198012345678", + }); + + let authRef!: Auth; + renderAuth((a) => { authRef = a; }); + + await vi.waitFor(() => { + expect(authRef.role()).toBe("viewer"); + expect(authRef.isAdmin()).toBe(false); + }); + }); + + it("logout clears role", async () => { + setAuthToken("stored-jwt"); + mockGetMe.mockResolvedValue({ + authenticated: true, + role: "admin", + steamId: "76561198012345678", + }); + mockLogout.mockResolvedValue(undefined); + + let authRef!: Auth; + const { findByText } = renderAuth((a) => { authRef = a; }); + + await findByText("true"); + expect(authRef.role()).toBe("admin"); + expect(authRef.isAdmin()).toBe(true); + + await authRef.logout(); + expect(authRef.role()).toBeNull(); + expect(authRef.isAdmin()).toBe(false); + }); }); diff --git a/ui/src/hooks/useAuth.tsx b/ui/src/hooks/useAuth.tsx index 0d7ad5ee0..15b0c1d73 100644 --- a/ui/src/hooks/useAuth.tsx +++ b/ui/src/hooks/useAuth.tsx @@ -1,9 +1,11 @@ -import { createContext, useContext, createSignal, onMount } from "solid-js"; +import { createContext, useContext, createSignal, createMemo, onMount } from "solid-js"; import type { JSX, Accessor } from "solid-js"; import { ApiClient, getAuthToken } from "../data/apiClient"; export interface Auth { authenticated: Accessor; + role: Accessor; + isAdmin: Accessor; steamId: Accessor; steamName: Accessor; steamAvatar: Accessor; @@ -14,7 +16,6 @@ export interface Auth { } const AUTH_ERROR_MESSAGES: Record = { - steam_denied: "Your Steam account is not authorized for admin access.", steam_error: "Steam login failed. Please try again.", }; @@ -25,6 +26,8 @@ const AuthContext = createContext(); */ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { const [authenticated, setAuthenticated] = createSignal(false); + const [role, setRole] = createSignal(null); + const isAdmin = createMemo(() => role() === "admin"); const [steamId, setSteamId] = createSignal(null); const [steamName, setSteamName] = createSignal(null); const [steamAvatar, setSteamAvatar] = createSignal(null); @@ -64,6 +67,7 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { try { const state = await api.getMe(); setAuthenticated(state.authenticated); + setRole(state.role ?? null); setSteamId(state.steamId ?? null); setSteamName(state.steamName ?? null); setSteamAvatar(state.steamAvatar ?? null); @@ -86,6 +90,7 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { await api.logout(); } finally { setAuthenticated(false); + setRole(null); setSteamId(null); setSteamName(null); setSteamAvatar(null); @@ -93,7 +98,7 @@ export function AuthProvider(props: { children: JSX.Element }): JSX.Element { }; return ( - + {props.children} ); From f6045aadc98682c8bc5df2cb3f610bae38355bda Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Thu, 5 Mar 2026 20:09:03 +0100 Subject: [PATCH 5/8] feat(ui): gate admin features behind isAdmin instead of authenticated AuthBadge shows ADMIN label only for admins, displays name for all logged-in users. MapTool and Upload gated behind isAdmin(). --- ui/src/components/AuthBadge.tsx | 10 ++++---- .../components/__tests__/AuthBadge.test.tsx | 24 +++++++++++++++---- ui/src/hooks/useAuth.tsx | 1 + .../recording-selector/RecordingSelector.tsx | 8 +++---- .../__tests__/RecordingSelector.test.tsx | 2 +- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/ui/src/components/AuthBadge.tsx b/ui/src/components/AuthBadge.tsx index e2c8d09e4..200c9e29e 100644 --- a/ui/src/components/AuthBadge.tsx +++ b/ui/src/components/AuthBadge.tsx @@ -11,7 +11,7 @@ import styles from "./AuthBadge.module.css"; * Calls useAuth() internally; no props needed. */ export function AuthBadge(): JSX.Element { - const { authenticated, steamName, steamId, steamAvatar, loginWithSteam, logout } = useAuth(); + const { authenticated, isAdmin, steamName, steamId, steamAvatar, loginWithSteam, logout } = useAuth(); const { t } = useI18n(); return ( @@ -25,14 +25,16 @@ export function AuthBadge(): JSX.Element { > <>
- A
}> + {(steamName() || "U")[0].toUpperCase()}}> {(url) => }
- {steamName() || steamId() || "Admin"} + {steamName() || steamId() || "User"}
-
ADMIN
+ +
ADMIN
+