From f1c2b7a53fcc2960acdfb1f7f8e70f91691c1474 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Wed, 29 Apr 2026 10:24:39 +0200 Subject: [PATCH] feat: implement HandleBoseAmazonToken and wire amazonService into Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GetAccountByRefreshToken to amazon.Service — the speaker sends the bare Atzr| refresh token (extracted from AmazonSecret JSON), not a surrogate, so lookup must match against Account.RefreshToken - Add amazonService field, SetAmazonService and IsAmazonConfigured to Server (step 5 essentials required by the handler) - Replace HandleBoseAmazonToken 501 stub with full implementation: lookup by refresh token → RefreshAccessToken; fallback to GetFreshToken; fallback to HandleBoseProxy if no service configured; scope intentionally omitted from response - Add handler tests covering the by-refresh-token path (mock LWA server), the default-account path, and the no-service fallback - Unlock assertions in post_oauth_token_amazon.http integration test Co-Authored-By: Claude Sonnet 4.6 --- pkg/service/amazon/service.go | 18 +- pkg/service/amazon/service_test.go | 2 +- pkg/service/amazon/zeroconf.go | 2 +- pkg/service/amazon/zeroconf_test.go | 2 +- pkg/service/handlers/handlers_oauth.go | 96 ++++++++++- pkg/service/handlers/handlers_oauth_test.go | 154 ++++++++++++++++++ pkg/service/handlers/server.go | 18 ++ pkg/service/spotify/zeroconf.go | 2 +- pkg/service/spotify/zeroconf_test.go | 2 +- pkg/service/zeroconf/zeroconf.go | 2 +- pkg/service/zeroconf/zeroconf_test.go | 2 +- .../http-client/post_oauth_token_amazon.http | 41 ++--- 12 files changed, 306 insertions(+), 35 deletions(-) diff --git a/pkg/service/amazon/service.go b/pkg/service/amazon/service.go index 6b7e7ad..39bac8d 100644 --- a/pkg/service/amazon/service.go +++ b/pkg/service/amazon/service.go @@ -352,6 +352,22 @@ func (s *Service) GetAccountBySecret(secret string) (*Account, bool) { return nil, false } +// GetAccountByRefreshToken retrieves an Amazon account by its current refresh token. +// Used by the token handler because the speaker sends back the actual LWA refresh token +// (extracted from the AmazonSecret JSON in Sources.xml), not a surrogate. +func (s *Service) GetAccountByRefreshToken(refreshToken string) (*Account, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, a := range s.accounts { + if a.RefreshToken == refreshToken { + return a, true + } + } + + return nil, false +} + func (s *Service) generateBoseSecret() string { prefix := "ba-" @@ -417,4 +433,4 @@ func (s *Service) load() error { log.Printf("[Amazon] Loaded %d account(s)", len(accounts)) return nil -} \ No newline at end of file +} diff --git a/pkg/service/amazon/service_test.go b/pkg/service/amazon/service_test.go index a10ed1b..0dfd53c 100644 --- a/pkg/service/amazon/service_test.go +++ b/pkg/service/amazon/service_test.go @@ -363,4 +363,4 @@ func TestSaveAndLoad(t *testing.T) { if u1.ExpiresAt != 1234567890 { t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt) } -} \ No newline at end of file +} diff --git a/pkg/service/amazon/zeroconf.go b/pkg/service/amazon/zeroconf.go index 5b86a19..a476b21 100644 --- a/pkg/service/amazon/zeroconf.go +++ b/pkg/service/amazon/zeroconf.go @@ -8,4 +8,4 @@ import "github.com/gesellix/bose-soundtouch/pkg/service/zeroconf" // zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc". func PushAmazonCredentials(zcBaseURL, username, accessToken string) error { return zeroconf.PushCredentials(zcBaseURL, username, accessToken) -} \ No newline at end of file +} diff --git a/pkg/service/amazon/zeroconf_test.go b/pkg/service/amazon/zeroconf_test.go index 185dd60..857ae7c 100644 --- a/pkg/service/amazon/zeroconf_test.go +++ b/pkg/service/amazon/zeroconf_test.go @@ -173,4 +173,4 @@ func readVarint(data []byte) (uint64, int) { } } return 0, len(data) -} \ No newline at end of file +} diff --git a/pkg/service/handlers/handlers_oauth.go b/pkg/service/handlers/handlers_oauth.go index 992a768..71133ed 100644 --- a/pkg/service/handlers/handlers_oauth.go +++ b/pkg/service/handlers/handlers_oauth.go @@ -8,6 +8,7 @@ import ( "strconv" + "github.com/gesellix/bose-soundtouch/pkg/service/amazon" "github.com/gesellix/bose-soundtouch/pkg/service/constants" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" "github.com/go-chi/chi/v5" @@ -98,13 +99,100 @@ func (s *Server) HandleBoseAccountToken(w http.ResponseWriter, r *http.Request) s.HandleBoseSpotifyToken(w, r) } -// HandleBoseAmazonToken is a stub for Amazon Music OAuth token handling. +// HandleBoseAmazonToken handles the Amazon Music token refresh request from the speaker. // POST /oauth/device/{deviceID}/music/musicprovider/20/token/cs1 -// Amazon Music API integration is not yet implemented. +// The speaker sends the bare refresh token extracted from the stored AmazonSecret JSON. func (s *Server) HandleBoseAmazonToken(w http.ResponseWriter, r *http.Request) { deviceID := chi.URLParam(r, "deviceID") - log.Printf("[Amazon] Token request for device %s — Amazon Music not yet supported", deviceID) - http.Error(w, "Amazon Music integration not yet supported", http.StatusNotImplemented) + log.Printf("[Amazon] Token request for device %s", deviceID) + + s.mu.RLock() + svc := s.amazonService + s.mu.RUnlock() + + if svc == nil { + log.Printf("[Amazon] Amazon service not configured, falling back to upstream") + s.HandleBoseProxy(w, r) + + return + } + + accounts := svc.GetAccounts() + if len(accounts) == 0 { + log.Printf("[Amazon] No Amazon accounts linked, falling back to upstream") + s.HandleBoseProxy(w, r) + + return + } + + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + + var tokenReq struct { + RefreshToken string `json:"refresh_token"` + GrantType string `json:"grant_type"` + Code string `json:"code"` + } + + _ = json.Unmarshal(body, &tokenReq) + + // The speaker extracts the bare refresh token from AmazonSecret JSON and sends it here. + secret := tokenReq.RefreshToken + if secret == "" { + secret = tokenReq.Code + } + + var ( + account *amazon.Account + accessToken string + userID string + ) + + if secret != "" { + if acc, ok := svc.GetAccountByRefreshToken(secret); ok { + account = acc + log.Printf("[Amazon] Found account for refresh token: %s", acc.UserID) + } + } + + if account != nil { + if err := svc.RefreshAccessToken(account); err != nil { + log.Printf("[Amazon] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err) + s.HandleBoseProxy(w, r) + + return + } + + accessToken = account.AccessToken + } else { + var err error + + accessToken, userID, err = svc.GetFreshToken() + if err != nil { + log.Printf("[Amazon] Failed to get fresh token: %v. Falling back to upstream", err) + s.HandleBoseProxy(w, r) + + return + } + + log.Printf("[Amazon] Using default account %s", userID) + } + + // Omit "scope" — Amazon Music scopes are undocumented; sending invented values + // risks firmware rejection. + response := map[string]interface{}{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + } + + 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("[Amazon] Failed to encode response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } } // HandleBoseSpotifyToken handles the Bose-specific Spotify token refresh request. diff --git a/pkg/service/handlers/handlers_oauth_test.go b/pkg/service/handlers/handlers_oauth_test.go index 056dd44..938cdb7 100644 --- a/pkg/service/handlers/handlers_oauth_test.go +++ b/pkg/service/handlers/handlers_oauth_test.go @@ -6,9 +6,11 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" + "github.com/gesellix/bose-soundtouch/pkg/service/amazon" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" "github.com/go-chi/chi/v5" @@ -116,6 +118,158 @@ func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) { } } +// TestHandleBoseAmazonToken_LocalResponse_ByRefreshToken verifies the account-lookup +// path: speaker sends its stored refresh token, handler refreshes via a mock LWA +// server and returns the new access token. +func TestHandleBoseAmazonToken_LocalResponse_ByRefreshToken(t *testing.T) { + // Mock LWA token endpoint + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.Form.Get("grant_type") != "refresh_token" { + t.Errorf("expected grant_type=refresh_token, got %s", r.Form.Get("grant_type")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": "Atza|new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "Atzr|new-refresh-token", + }) + })) + defer tokenServer.Close() + + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + + amazonDir := filepath.Join(tmpDir, "amazon") + _ = os.MkdirAll(amazonDir, 0755) + + account := map[string]interface{}{ + "amzn1.account.USER1": map[string]interface{}{ + "user_id": "amzn1.account.USER1", + "display_name": "Amazon User", + "access_token": "Atza|old-access-token", + "refresh_token": "Atzr|stored-refresh-token", + "expires_at": time.Now().Add(-1 * time.Hour).Unix(), // expired + }, + } + data, _ := json.Marshal(account) + _ = os.WriteFile(filepath.Join(amazonDir, "accounts.json"), data, 0644) + + as := amazon.NewAmazonService("client-id", "client-secret", "ueberboese-login://amazon", tmpDir) + _ = as.Load() + as.SetEndpoints(tokenServer.URL, "") + + server.SetAmazonService(as) + + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken) + + // Speaker sends its stored refresh token (extracted from AmazonSecret JSON) + body := strings.NewReader(`{"grant_type":"refresh_token","refresh_token":"Atzr|stored-refresh-token","code":"","redirect_uri":""}`) + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/20/token/cs1", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + 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"] != "Atza|new-access-token" { + t.Errorf("Expected new access token, got %v", resp["access_token"]) + } + if _, hasScope := resp["scope"]; hasScope { + t.Error("Response must NOT include 'scope' for Amazon") + } +} + +// TestHandleBoseAmazonToken_LocalResponse_DefaultAccount verifies the fallback path: +// no matching refresh token in body, handler uses GetFreshToken on the first account. +func TestHandleBoseAmazonToken_LocalResponse_DefaultAccount(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + + amazonDir := filepath.Join(tmpDir, "amazon") + _ = os.MkdirAll(amazonDir, 0755) + + account := map[string]interface{}{ + "amzn1.account.USER1": map[string]interface{}{ + "user_id": "amzn1.account.USER1", + "display_name": "Amazon User", + "access_token": "Atza|valid-access-token", + "refresh_token": "Atzr|valid-refresh-token", + "expires_at": time.Now().Add(1 * time.Hour).Unix(), + }, + } + data, _ := json.Marshal(account) + _ = os.WriteFile(filepath.Join(amazonDir, "accounts.json"), data, 0644) + + as := amazon.NewAmazonService("client-id", "client-secret", "ueberboese-login://amazon", tmpDir) + _ = as.Load() + server.SetAmazonService(as) + + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken) + + // No body — handler falls back to GetFreshToken (no network call needed, token is fresh) + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/20/token/cs1", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + 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"] != "Atza|valid-access-token" { + t.Errorf("Expected access_token 'Atza|valid-access-token', got %v", resp["access_token"]) + } + if resp["token_type"] != "Bearer" { + t.Errorf("Expected token_type 'Bearer', got %v", resp["token_type"]) + } + if _, hasScope := resp["scope"]; hasScope { + t.Error("Response must NOT include 'scope' for Amazon") + } +} + +func TestHandleBoseAmazonToken_FallbackToProxy(t *testing.T) { + tmpDir := t.TempDir() + ds := datastore.NewDataStore(tmpDir) + server := NewServer(ds, nil, "http://localhost", false, false, false) + server.SetMirrorSettings(true, nil, nil, "") + + r := chi.NewRouter() + r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken) + + req := httptest.NewRequest("POST", "/oauth/device/DEVICE123/music/musicprovider/20/token/cs1", nil) + req.Host = "localhost" + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Header().Get("X-Proxy-Origin") == "self" { + t.Error("Expected fallback to proxy, but got X-Proxy-Origin: self") + } +} + func TestHandleBoseLegacyToken(t *testing.T) { tmpDir := t.TempDir() ds := datastore.NewDataStore(tmpDir) diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index d5c4fa2..48b9d2a 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -14,6 +14,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/discovery" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/amazon" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" @@ -56,6 +57,7 @@ type Server struct { spotifyClientSecret string spotifyRedirectURI string spotifyService *spotify.Service + amazonService *amazon.Service } // RequestSnapshot represents an immutable snapshot of an HTTP request. @@ -339,6 +341,22 @@ func (s *Server) SetInternalPaths(paths []string) { s.internalPaths = paths } +// SetAmazonService sets the Amazon OAuth service. +func (s *Server) SetAmazonService(as *amazon.Service) { + s.mu.Lock() + defer s.mu.Unlock() + + s.amazonService = as +} + +// IsAmazonConfigured returns whether Amazon Music integration is configured. +func (s *Server) IsAmazonConfigured() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.amazonService != nil +} + // SetSpotifyService sets the Spotify OAuth service. func (s *Server) SetSpotifyService(ss *spotify.Service) { s.mu.Lock() diff --git a/pkg/service/spotify/zeroconf.go b/pkg/service/spotify/zeroconf.go index c5eff20..1fd0e37 100644 --- a/pkg/service/spotify/zeroconf.go +++ b/pkg/service/spotify/zeroconf.go @@ -13,4 +13,4 @@ func ZeroConfGetInfo(zcBaseURL string) ([]byte, error) { // zcBaseURL is the base URL of the ZeroConf endpoint, e.g. "http://192.168.1.10:8200/zc". func PushSpotifyCredentials(zcBaseURL, username, accessToken string) error { return zeroconf.PushCredentials(zcBaseURL, username, accessToken) -} \ No newline at end of file +} diff --git a/pkg/service/spotify/zeroconf_test.go b/pkg/service/spotify/zeroconf_test.go index 3d1bb72..235a5cd 100644 --- a/pkg/service/spotify/zeroconf_test.go +++ b/pkg/service/spotify/zeroconf_test.go @@ -199,4 +199,4 @@ func readProtoVarint(data []byte) (uint64, int) { } } return 0, len(data) -} \ No newline at end of file +} diff --git a/pkg/service/zeroconf/zeroconf.go b/pkg/service/zeroconf/zeroconf.go index 3579526..278bf0e 100644 --- a/pkg/service/zeroconf/zeroconf.go +++ b/pkg/service/zeroconf/zeroconf.go @@ -297,4 +297,4 @@ func writeVarint(buf *bytes.Buffer, v uint64) { } buf.WriteByte(byte(v)) -} \ No newline at end of file +} diff --git a/pkg/service/zeroconf/zeroconf_test.go b/pkg/service/zeroconf/zeroconf_test.go index 6e70d2e..02af741 100644 --- a/pkg/service/zeroconf/zeroconf_test.go +++ b/pkg/service/zeroconf/zeroconf_test.go @@ -311,4 +311,4 @@ func readProtoVarint(data []byte) (uint64, int) { } } return 0, len(data) -} \ No newline at end of file +} diff --git a/tests/integration/http-client/post_oauth_token_amazon.http b/tests/integration/http-client/post_oauth_token_amazon.http index 834a2c0..1a66c4c 100644 --- a/tests/integration/http-client/post_oauth_token_amazon.http +++ b/tests/integration/http-client/post_oauth_token_amazon.http @@ -14,31 +14,26 @@ Content-Type: application/json } > {% - // TODO: upgrade to 200 + JSON structure assertions once the Amazon service is implemented - // and a mock Amazon OAuth server is wired into the CI docker-compose (mirroring spotify-mock). - client.test("Route is registered (stub returns 501, not 404)", function() { - client.assert(response.status === 501, "Expected 501 Not Implemented from stub, got " + response.status); + client.test("Request executed successfully", function() { + client.assert(response.status === 200, "Response status is not 200"); }); - // client.test("Request executed successfully", function() { - // client.assert(response.status === 200, "Response status is not 200"); - // }); + client.test("Response content type is JSON", function() { + var type = response.contentType.mimeType; + client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); + }); - // client.test("Response content type is JSON", function() { - // var type = response.contentType.mimeType; - // client.assert(type === "application/json", "Expected 'application/json' but received '" + type + "'"); - // }); + client.test("Response body has expected structure", function() { + client.assert(response.body.hasOwnProperty("access_token"), "Response body missing 'access_token'"); + client.assert(response.body.access_token.length > 0, "access_token is empty"); - // client.test("Response body has expected structure", function() { - // client.assert(response.body.hasOwnProperty("access_token"), "Response body missing 'access_token'"); - // client.assert(response.body.access_token.length > 0, "access_token is empty"); - // - // client.assert(response.body.hasOwnProperty("expires_in"), "Response body missing 'expires_in'"); - // client.assert(typeof response.body.expires_in === "number", "expires_in is not a number"); - // - // client.assert(response.body.hasOwnProperty("token_type"), "Response body missing 'token_type'"); - // client.assert(response.body.token_type === "Bearer", "token_type is not 'Bearer'"); - // - // // Amazon omits 'scope' — do not assert its presence. - // }); + client.assert(response.body.hasOwnProperty("expires_in"), "Response body missing 'expires_in'"); + client.assert(typeof response.body.expires_in === "number", "expires_in is not a number"); + + client.assert(response.body.hasOwnProperty("token_type"), "Response body missing 'token_type'"); + client.assert(response.body.token_type === "Bearer", "token_type is not 'Bearer'"); + + // Amazon omits 'scope' — do not assert its presence. + client.assert(!response.body.hasOwnProperty("scope"), "Response must NOT include 'scope' for Amazon"); + }); %}