feat: implement HandleBoseAmazonToken and wire amazonService into Server

- 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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-04-29 20:30:06 +02:00
co-authored by Claude Sonnet 4.6
parent 466e9eca97
commit f1c2b7a53f
12 changed files with 306 additions and 35 deletions
+17 -1
View File
@@ -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
}
}
+1 -1
View File
@@ -363,4 +363,4 @@ func TestSaveAndLoad(t *testing.T) {
if u1.ExpiresAt != 1234567890 {
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
}
}
}
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -173,4 +173,4 @@ func readVarint(data []byte) (uint64, int) {
}
}
return 0, len(data)
}
}
+92 -4
View File
@@ -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.
+154
View File
@@ -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)
+18
View File
@@ -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()
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -199,4 +199,4 @@ func readProtoVarint(data []byte) (uint64, int) {
}
}
return 0, len(data)
}
}
+1 -1
View File
@@ -297,4 +297,4 @@ func writeVarint(buf *bytes.Buffer, v uint64) {
}
buf.WriteByte(byte(v))
}
}
+1 -1
View File
@@ -311,4 +311,4 @@ func readProtoVarint(data []byte) (uint64, int) {
}
}
return 0, len(data)
}
}
@@ -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");
});
%}