mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
466e9eca97
commit
f1c2b7a53f
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,4 +363,4 @@ func TestSaveAndLoad(t *testing.T) {
|
||||
if u1.ExpiresAt != 1234567890 {
|
||||
t.Errorf("expected ExpiresAt 1234567890, got %d", u1.ExpiresAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +173,4 @@ func readVarint(data []byte) (uint64, int) {
|
||||
}
|
||||
}
|
||||
return 0, len(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,4 +199,4 @@ func readProtoVarint(data []byte) (uint64, int) {
|
||||
}
|
||||
}
|
||||
return 0, len(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,4 +297,4 @@ func writeVarint(buf *bytes.Buffer, v uint64) {
|
||||
}
|
||||
|
||||
buf.WriteByte(byte(v))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
%}
|
||||
|
||||
Reference in New Issue
Block a user