Skip to content
Closed
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
70 changes: 70 additions & 0 deletions backend/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package auth

import (
"encoding/json"
"net/http"
"strings"
)

type Validator struct {
token string
}

type RequestTokenOptions struct {
AllowQuery bool
}

func NewValidator(token string) *Validator {
return &Validator{token: token}
}

func (v *Validator) ValidateToken(token string) bool {
return token != "" && token == v.token
}

func TokenFromRequest(r *http.Request, options RequestTokenOptions) string {
if token := TokenFromAuthorizationHeader(r.Header.Get("Authorization")); token != "" {
return token
}

if token := r.Header.Get("X-API-Token"); token != "" {
return token
}

if token := r.Header.Get("token"); token != "" {
return token
}

if options.AllowQuery {
return r.URL.Query().Get("token")
}

return ""
}

func TokenFromAuthorizationHeader(value string) string {
if value == "" {
return ""
}

prefix, token, ok := strings.Cut(value, " ")
if !ok || !strings.EqualFold(prefix, "Bearer") {
return ""
}

return strings.TrimSpace(token)
}

func WriteAuthenticationError(w http.ResponseWriter) {
writeJSONError(w, http.StatusInternalServerError, "Authentication error")
}

func WriteUnauthorized(w http.ResponseWriter) {
writeJSONError(w, http.StatusUnauthorized, "Invalid API token")
}

func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
Comment on lines +66 to +70
53 changes: 53 additions & 0 deletions backend/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package auth

import (
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
)

func TestTokenFromAuthorizationHeader(t *testing.T) {
assert.Equal(t, "test-token", TokenFromAuthorizationHeader("Bearer test-token"))
assert.Equal(t, "test-token", TokenFromAuthorizationHeader("bearer test-token"))
assert.Equal(t, "", TokenFromAuthorizationHeader("Basic test-token"))
assert.Equal(t, "", TokenFromAuthorizationHeader("test-token"))
assert.Equal(t, "", TokenFromAuthorizationHeader(""))
}

func TestTokenFromRequest(t *testing.T) {
t.Run("prefers bearer header", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/data/cpu?token=query-token", nil)
req.Header.Set("Authorization", "Bearer bearer-token")
req.Header.Set("X-API-Token", "header-token")

assert.Equal(t, "bearer-token", TokenFromRequest(req, RequestTokenOptions{AllowQuery: true}))
})

t.Run("falls back to legacy headers", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/data/cpu", nil)
req.Header.Set("X-API-Token", "header-token")

assert.Equal(t, "header-token", TokenFromRequest(req, RequestTokenOptions{}))

req = httptest.NewRequest("GET", "/api/data/cpu", nil)
req.Header.Set("token", "legacy-token")

assert.Equal(t, "legacy-token", TokenFromRequest(req, RequestTokenOptions{}))
})

t.Run("uses query token when enabled", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/media/file/data?token=query-token", nil)

assert.Equal(t, "query-token", TokenFromRequest(req, RequestTokenOptions{AllowQuery: true}))
assert.Equal(t, "", TokenFromRequest(req, RequestTokenOptions{}))
})
}

func TestValidatorValidateToken(t *testing.T) {
validator := NewValidator("expected-token")

assert.True(t, validator.ValidateToken("expected-token"))
assert.False(t, validator.ValidateToken("wrong-token"))
assert.False(t, validator.ValidateToken(""))
}
5 changes: 4 additions & 1 deletion backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"log/slog"

backend_auth "github.com/timmo001/system-bridge/backend/auth"
api_http "github.com/timmo001/system-bridge/backend/http"
"github.com/timmo001/system-bridge/backend/mcp"
"github.com/timmo001/system-bridge/backend/websocket"
Expand Down Expand Up @@ -90,6 +91,7 @@ func (b *Backend) Run(ctx context.Context) error {

// Create a new HTTP server mux
mux := http.NewServeMux()
authValidator := backend_auth.NewValidator(b.token)

// Set up WebSocket endpoint
mux.HandleFunc("/api/websocket", func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -118,6 +120,7 @@ func (b *Backend) Run(ctx context.Context) error {
// Set up module data endpoint
mux.HandleFunc("/api/data/", api_http.GetModuleDataHandler(
b.dataStore,
authValidator,
))

// Set up health check endpoint
Expand All @@ -142,7 +145,7 @@ func (b *Backend) Run(ctx context.Context) error {
}
})

mux.HandleFunc("/api/media/file/data", api_http.ServeMediaFileDataHandler)
mux.HandleFunc("/api/media/file/data", api_http.ServeMediaFileDataHandler(authValidator))

// Set up SPA file server (must be last to avoid catching API routes)
subFS, err := fs.Sub(b.webClientContent, "web-client/dist")
Expand Down
29 changes: 5 additions & 24 deletions backend/http/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,17 @@ import (

"log/slog"

backend_auth "github.com/timmo001/system-bridge/backend/auth"
"github.com/timmo001/system-bridge/data"
"github.com/timmo001/system-bridge/types"
"github.com/timmo001/system-bridge/utils"
)

// GetModuleDataHandler handles requests to get data for a specific module
func GetModuleDataHandler(dataStore *data.DataStore) http.HandlerFunc {
func GetModuleDataHandler(dataStore *data.DataStore, validator *backend_auth.Validator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
expectedToken, err := utils.LoadToken()
if err != nil {
slog.Error("Failed to load token for authentication", "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Authentication error"}); err != nil {
slog.Error("Failed to encode response", "error", err)
}
return
}

// Check for API token in both X-API-Token and token headers
token := r.Header.Get("X-API-Token")
if token == "" {
token = r.Header.Get("token")
}
if token != expectedToken {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Invalid API token"}); err != nil {
slog.Error("Failed to encode response", "error", err)
}
token := backend_auth.TokenFromRequest(r, backend_auth.RequestTokenOptions{})
if !validator.ValidateToken(token) {
backend_auth.WriteUnauthorized(w)
return
Comment on lines +15 to 20
}

Expand Down
66 changes: 66 additions & 0 deletions backend/http/data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package http

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
backend_auth "github.com/timmo001/system-bridge/backend/auth"
"github.com/timmo001/system-bridge/data"
)

func TestGetModuleDataHandlerAuth(t *testing.T) {
dataStore, err := data.NewDataStore()
require.NoError(t, err)

handler := GetModuleDataHandler(dataStore, backend_auth.NewValidator("test-token"))

t.Run("rejects missing token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data/cpu", nil)
rr := httptest.NewRecorder()

handler(rr, req)

assert.Equal(t, http.StatusUnauthorized, rr.Code)
})

t.Run("accepts bearer token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data/cpu", nil)
req.Header.Set("Authorization", "Bearer test-token")
rr := httptest.NewRecorder()

handler(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)
})

t.Run("accepts legacy x-api-token header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data/cpu", nil)
req.Header.Set("X-API-Token", "test-token")
rr := httptest.NewRecorder()

handler(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)
})
}

func TestGetModuleDataHandlerReturnsJSONError(t *testing.T) {
dataStore, err := data.NewDataStore()
require.NoError(t, err)

handler := GetModuleDataHandler(dataStore, backend_auth.NewValidator("test-token"))
req := httptest.NewRequest(http.MethodGet, "/api/data/unknown", nil)
req.Header.Set("Authorization", "Bearer test-token")
rr := httptest.NewRecorder()

handler(rr, req)

assert.Equal(t, http.StatusNotFound, rr.Code)
var body map[string]string
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &body))
assert.Equal(t, "Module not found", body["error"])
}
Loading
Loading