From 6cf511e7e5cadee162f0ff687ccdbd3588db552f Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 18 Mar 2026 23:22:32 +0100 Subject: [PATCH] Implement local Bose Spotify OAuth token handling and fix linting issues in tests (#119) Co-authored-by: Junie --- cmd/soundtouch-service/main.go | 2 + pkg/service/handlers/handlers_oauth.go | 70 ++++++++++ pkg/service/handlers/handlers_oauth_test.go | 134 ++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 pkg/service/handlers/handlers_oauth.go create mode 100644 pkg/service/handlers/handlers_oauth_test.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index a85a078..d7e66d7 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -724,6 +724,8 @@ func setupRouter(server *handlers.Server) *chi.Mux { }) r.Route("/oauth", func(r chi.Router) { + r.Post("/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken) + r.Post("/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken) r.HandleFunc("/*", server.HandleBoseProxy) }) diff --git a/pkg/service/handlers/handlers_oauth.go b/pkg/service/handlers/handlers_oauth.go new file mode 100644 index 0000000..4cb9846 --- /dev/null +++ b/pkg/service/handlers/handlers_oauth.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request from the speaker. +// POST /oauth/device/{deviceID}/music/musicprovider/15/token/cs3 +func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceID") + log.Printf("[Spotify Proxy] Intercepted token request for device %s", deviceID) + + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + log.Printf("[Spotify Proxy] Spotify service not configured, falling back to upstream") + s.HandleBoseProxy(w, r) + + return + } + + accounts := svc.GetAccounts() + if len(accounts) == 0 { + log.Printf("[Spotify Proxy] No Spotify accounts linked, falling back to upstream") + s.HandleBoseProxy(w, r) + + return + } + + // We use the first linked account. + accessToken, _, err := svc.GetFreshToken() + if err != nil { + log.Printf("[Spotify Proxy] Failed to get fresh token: %v. Falling back to upstream", err) + s.HandleBoseProxy(w, r) + + return + } + + // Format response as expected by Bose firmware. + // Based on observed interactions, it's a JSON object with access_token. + // The "scope" and other fields might be needed by some firmware versions. + response := map[string]interface{}{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + // These scopes are typical for what Bose requests. + "scope": "playlist-read-private playlist-read-collaborative streaming user-library-read user-library-modify playlist-modify-private playlist-modify-public user-read-email user-read-private user-top-read", + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Proxy-Origin", "self") + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("[Spotify Proxy] Failed to encode response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// HandleBoseSpotifyLegacyToken handles the Bose-specific Spotify token refresh request (legacy or variant). +// POST /oauth/device/{deviceID}/music/musicprovider/15/token +func (s *Server) HandleBoseSpotifyLegacyToken(w http.ResponseWriter, r *http.Request) { + // Some firmware might use a slightly different path. + s.HandleBoseSpotifyToken(w, r) +} diff --git a/pkg/service/handlers/handlers_oauth_test.go b/pkg/service/handlers/handlers_oauth_test.go new file mode 100644 index 0000000..6476627 --- /dev/null +++ b/pkg/service/handlers/handlers_oauth_test.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/spotify" + "github.com/go-chi/chi/v5" +) + +func TestHandleBoseSpotifyToken_LocalResponse(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + + // Wait, I can't easily inject an account into spotify.Service from here because fields are private. + // Let's check if there's any other way. + // I could mock the spotify.Service if it was an interface, but it's a struct. + + // ss.load() expects accounts in tmpDir/spotify/accounts.json + spotifyDir := filepath.Join(tmpDir, "spotify") + _ = os.MkdirAll(spotifyDir, 0755) + + account := map[string]interface{}{ + "user1": map[string]interface{}{ + "user_id": "user1", + "display_name": "Test User", + "access_token": "valid-token", + "refresh_token": "refresh-token", + "expires_at": time.Now().Add(1 * time.Hour).Unix(), + }, + } + data, err := json.Marshal(account) + if err != nil { + t.Fatalf("Failed to marshal account: %v", err) + } + _ = os.WriteFile(filepath.Join(spotifyDir, "accounts.json"), data, 0644) + + // Initialize ss so it loads the data + ss := spotify.NewSpotifyService("client-id", "client-secret", "http://localhost/callback", tmpDir) + + server.SetSpotifyService(ss) + + // chi.URLParam works when using chi router + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken) + + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if w.Header().Get("X-Proxy-Origin") != "self" { + t.Errorf("Expected X-Proxy-Origin: self, got %s", w.Header().Get("X-Proxy-Origin")) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp["access_token"] != "valid-token" { + t.Errorf("Expected access_token 'valid-token', got %v", resp["access_token"]) + } +} + +func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + + // Mirroring must be enabled for HandleBoseProxy to work (based on previous changes) + // Actually I reverted that, so it should work regardless of MirrorEnabled now. + server.SetMirrorSettings(true, nil, "") + + // chi.URLParam works when using chi router + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token/cs3", server.HandleBoseSpotifyToken) + + // Since there's no Spotify service, it should fall back to HandleBoseProxy. + // HandleBoseProxy will try to contact streaming.bose.com. + // We can check if it returns a 502 or 404 (since we are not actually proxying to real Bose). + + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token/cs3", nil) + req.Host = "localhost" // use localhost to avoid real network call + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + // If it fell back to proxy, it should NOT have X-Proxy-Origin: self + if w.Header().Get("X-Proxy-Origin") == "self" { + t.Errorf("Expected fallback to proxy, but got X-Proxy-Origin: self") + } + + // HandleBoseProxy sets X-Proxy-Origin: upstream + if w.Header().Get("X-Proxy-Origin") != "upstream" { + // It might fail before setting the header if the target host is invalid, + // but our HandleBoseProxy sets it in ModifyResponse. + // If it fails to connect, it might return 502 without the header. + if w.Code != http.StatusBadGateway && w.Code != http.StatusNotFound { + t.Errorf("Expected fallback to proxy (upstream), got status %d and origin %s", w.Code, w.Header().Get("X-Proxy-Origin")) + } + } +} + +func TestHandleBoseSpotifyLegacyToken(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/15/token", server.HandleBoseSpotifyLegacyToken) + + // Since we are not configuring Spotify, it should fall back to proxy + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/15/token", nil) + req.Host = "localhost" + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Header().Get("X-Proxy-Origin") == "self" { + t.Errorf("Expected fallback to proxy") + } +}