// Package amazon provides Amazon Music (Login with Amazon) OAuth integration // and token management for the SoundTouch service. package amazon import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" ) const ( // AmazonAuthorizeURL is the Login with Amazon (LWA) authorization endpoint. AmazonAuthorizeURL = "https://www.amazon.com/ap/oa" // AmazonTokenURL is the LWA token endpoint. AmazonTokenURL = "https://api.amazon.com/auth/o2/token" // AmazonProfileURL is the LWA user profile endpoint. AmazonProfileURL = "https://api.amazon.com/user/profile" // AmazonScopes are the OAuth scopes for account linking. // amazon_music:access is required for music-api.amazon.com but is only available // to device client IDs (Amazon Music partner apps), not standard application // client IDs (amzn1.application-oa2-client.*). Requesting it returns a 400 // lwa-invalid-parameter-bad-scope error from the LWA authorization endpoint. AmazonScopes = "profile" ) // Account represents a stored Amazon account with tokens. type Account struct { UserID string `json:"user_id"` DisplayName string `json:"display_name"` Email string `json:"email"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` BoseSecret string `json:"bose_secret,omitempty"` // SiteID is written into the AmazonSecret credential envelope. Its origin is // unconfirmed (may be a static Bose partner ID or a per-user Music API value). SiteID string `json:"site_id,omitempty"` } // Service manages Amazon OAuth flow and token lifecycle. type Service struct { clientID string clientSecret string redirectURI string dataDir string mu sync.RWMutex accounts map[string]*Account // Overridable URLs for testing tokenURL string profileURL string } // NewAmazonService creates a new Service and loads any persisted accounts. func NewAmazonService(clientID, clientSecret, redirectURI, dataDir string) *Service { return &Service{ clientID: clientID, clientSecret: clientSecret, redirectURI: redirectURI, dataDir: dataDir, accounts: make(map[string]*Account), tokenURL: AmazonTokenURL, profileURL: AmazonProfileURL, } } // Load loads persisted accounts from disk. func (s *Service) Load() error { return s.load() } // SetEndpoints allows overriding default Amazon API endpoints (for testing). func (s *Service) SetEndpoints(tokenURL, profileURL string) { s.mu.Lock() defer s.mu.Unlock() s.tokenURL = tokenURL s.profileURL = profileURL } // BuildAuthorizeURL constructs the LWA OAuth authorization URL. func (s *Service) BuildAuthorizeURL(state string) string { params := url.Values{ "client_id": {s.clientID}, "response_type": {"code"}, "redirect_uri": {s.redirectURI}, "scope": {AmazonScopes}, } if state != "" { params.Set("state", state) } return AmazonAuthorizeURL + "?" + params.Encode() } // ExchangeCodeAndStore exchanges an authorization code for tokens, // fetches the user profile, and stores the account. func (s *Service) ExchangeCodeAndStore(code string) error { tokenResp, err := s.exchangeCode(code) if err != nil { return fmt.Errorf("token exchange: %w", err) } accessToken, _ := tokenResp["access_token"].(string) refreshToken, _ := tokenResp["refresh_token"].(string) expiresIn, _ := tokenResp["expires_in"].(float64) if expiresIn == 0 { expiresIn = 3600 } profile, err := s.getUserProfile(accessToken) if err != nil { return fmt.Errorf("fetch profile: %w", err) } // LWA profile uses "user_id" and "name" (not "id" and "display_name" like Spotify). userID, _ := profile["user_id"].(string) displayName, _ := profile["name"].(string) email, _ := profile["email"].(string) boseSecret := s.generateBoseSecret() account := &Account{ UserID: userID, DisplayName: displayName, Email: email, AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: time.Now().Unix() + int64(expiresIn), BoseSecret: boseSecret, } s.mu.Lock() s.accounts[userID] = account s.mu.Unlock() if err := s.save(); err != nil { return fmt.Errorf("save accounts: %w", err) } log.Printf("[Amazon] Account linked: %s (%s)", displayName, userID) return nil } // exchangeCode exchanges an authorization code for tokens. // Amazon LWA requires client_id and client_secret as POST body fields, // not as HTTP Basic Auth (unlike Spotify). func (s *Service) exchangeCode(code string) (map[string]interface{}, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURI}, "client_id": {s.clientID}, "client_secret": {s.clientSecret}, } req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("token request: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } return result, nil } func (s *Service) getUserProfile(accessToken string) (map[string]interface{}, error) { req, err := http.NewRequest(http.MethodGet, s.profileURL, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("profile request: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("profile fetch failed (%d): %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse profile: %w", err) } return result, nil } // RefreshAccessToken refreshes the access token for the given account. // Amazon LWA requires client credentials as POST body fields. func (s *Service) RefreshAccessToken(account *Account) error { data := url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {account.RefreshToken}, "client_id": {s.clientID}, "client_secret": {s.clientSecret}, } req, err := http.NewRequest(http.MethodPost, s.tokenURL, strings.NewReader(data.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("refresh request: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("token refresh failed (%d): %s", resp.StatusCode, string(body)) } var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { return fmt.Errorf("parse response: %w", err) } s.mu.Lock() account.AccessToken, _ = result["access_token"].(string) expiresIn, _ := result["expires_in"].(float64) if expiresIn == 0 { expiresIn = 3600 } account.ExpiresAt = time.Now().Unix() + int64(expiresIn) if newRefresh, ok := result["refresh_token"].(string); ok && newRefresh != "" { account.RefreshToken = newRefresh } s.mu.Unlock() if err := s.save(); err != nil { return fmt.Errorf("save accounts: %w", err) } return nil } // GetFreshToken returns a valid access token and username, refreshing if needed. func (s *Service) GetFreshToken() (accessToken, username string, err error) { s.mu.RLock() if len(s.accounts) == 0 { s.mu.RUnlock() return "", "", fmt.Errorf("no Amazon accounts linked") } var account *Account for _, a := range s.accounts { account = a break } s.mu.RUnlock() // Check if token needs refresh (expired or within 60s of expiry) if account.ExpiresAt < time.Now().Unix()+60 { if err := s.RefreshAccessToken(account); err != nil { return "", "", fmt.Errorf("refresh token: %w", err) } } s.mu.RLock() defer s.mu.RUnlock() return account.AccessToken, account.UserID, nil } // GetAccounts returns a copy of all accounts with tokens stripped for API responses. func (s *Service) GetAccounts() []Account { s.mu.RLock() defer s.mu.RUnlock() result := make([]Account, 0, len(s.accounts)) for _, a := range s.accounts { result = append(result, Account{ UserID: a.UserID, DisplayName: a.DisplayName, Email: a.Email, ExpiresAt: a.ExpiresAt, BoseSecret: a.BoseSecret, // AccessToken and RefreshToken deliberately omitted }) } return result } // GetAccountBySecret retrieves an Amazon account by its Bose surrogate secret. func (s *Service) GetAccountBySecret(secret string) (*Account, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, a := range s.accounts { if a.BoseSecret == secret { return a, true } } return nil, false } // GetAllAccounts returns all accounts including tokens. Used internally by // bridgeAmazonToMarge to build the AmazonSecret credential envelope. func (s *Service) GetAllAccounts() []*Account { s.mu.RLock() defer s.mu.RUnlock() result := make([]*Account, 0, len(s.accounts)) for _, a := range s.accounts { result = append(result, a) } return result } // 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-" b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) } return prefix + hex.EncodeToString(b) } // save persists accounts to disk as JSON. func (s *Service) save() error { s.mu.RLock() data := make(map[string]*Account, len(s.accounts)) for k, v := range s.accounts { data[k] = v } s.mu.RUnlock() dir := filepath.Join(s.dataDir, "amazon") if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create directory: %w", err) } jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("marshal accounts: %w", err) } path := filepath.Join(dir, "accounts.json") if err := os.WriteFile(path, jsonData, 0600); err != nil { return fmt.Errorf("write file: %w", err) } return nil } // load reads persisted accounts from disk. func (s *Service) load() error { path := filepath.Join(s.dataDir, "amazon", "accounts.json") jsonData, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil // No accounts file yet, not an error } return fmt.Errorf("read file: %w", err) } var accounts map[string]*Account if err := json.Unmarshal(jsonData, &accounts); err != nil { return fmt.Errorf("unmarshal accounts: %w", err) } s.mu.Lock() s.accounts = accounts s.mu.Unlock() log.Printf("[Amazon] Loaded %d account(s)", len(accounts)) return nil }