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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
6 changes: 3 additions & 3 deletions internal/server/handler_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
28 changes: 15 additions & 13 deletions internal/server/handler_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,20 @@ func (h *Handler) SteamCallback(w http.ResponseWriter, r *http.Request) {
return
}

// Check allowlist
if !isSteamIDAllowed(steamID, h.setting.Admin.AllowedSteamIDs) {
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
if h.setting.Admin.SteamAPIKey != "" {
claimOpts := []ClaimOption{WithRole(role)}
Comment thread
fank marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -183,14 +185,19 @@ 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)
if token == "" || h.jwt.Validate(token) != nil {
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
}
Comment thread
fank marked this conversation as resolved.
next.ServeHTTP(w, r)
})
}
Expand All @@ -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 {
Expand Down
129 changes: 99 additions & 30 deletions internal/server/handler_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -103,21 +103,32 @@ 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"})
rec := httptest.NewRecorder()

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"})
Expand All @@ -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) {
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -409,16 +410,26 @@ 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"})
rec := httptest.NewRecorder()

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) {
Expand All @@ -428,7 +439,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)
Expand All @@ -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) {
Expand All @@ -464,7 +482,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)
Expand All @@ -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)
}
8 changes: 8 additions & 0 deletions internal/server/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 10 additions & 10 deletions internal/server/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading