Fix a complete flow for Spotify registration, preset

This commit is contained in:
Tobias Gesellchen
2026-04-06 21:15:15 +02:00
parent fea6df32f3
commit 153d387aaf
13 changed files with 401 additions and 20 deletions
+1
View File
@@ -119,6 +119,7 @@ test-http-client:
/workdir/spotify_registration.http \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_bmx_services.http \
@@ -159,6 +159,16 @@ The app notifies the physical SoundTouch speaker about the new source. This is u
---
## Implementation in SoundTouch-Service
This project implements the "Bose-mediated token" flow as follows:
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
---
## Placeholders and Constants
| Placeholder | Description |
+1
View File
@@ -709,6 +709,7 @@ type FullResponseSource struct {
Username string `json:"username" xml:"username"`
Account string `json:"account,omitempty" xml:"account,attr,omitempty"`
SourceLabel string `json:"source_label" xml:"-"`
SecretType string `json:"secret_type,omitempty" xml:"secretType,attr,omitempty"`
}
// FullResponsePreset represents a preset specifically for the /full response.
+50
View File
@@ -122,4 +122,54 @@ func TestSpotifyBridge(t *testing.T) {
if !speakerReceived {
t.Errorf("Speaker did not receive /setMusicServiceOAuthAccount notification")
}
// 3. Verify Token Refresh via Surrogate
// Now simulate the speaker asking for a fresh token using the surrogate secret it received.
// We need to find the surrogate first.
sources, _ = ds.GetConfiguredSources("acc123", "DEV123")
var surrogate string
for _, src := range sources {
if src.SourceKey.Type == "SPOTIFY" {
surrogate = src.Secret
break
}
}
if surrogate == "" {
t.Fatal("Could not find surrogate token in Marge sources")
}
if len(surrogate) != 32 {
t.Errorf("Expected surrogate to be 32 hex chars, got %s", surrogate)
}
// Request refresh
refreshReqBody := map[string]string{
"grant_type": "refresh_token",
"refresh_token": surrogate,
}
body, err := json.Marshal(refreshReqBody)
if err != nil {
t.Fatalf("Failed to marshal refresh request: %v", err)
}
refreshReq := httptest.NewRequest("POST", "/oauth/device/DEV123/music/musicprovider/15/token/cs3", strings.NewReader(string(body)))
refreshW := httptest.NewRecorder()
// Need to register the route for testing
r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.ServeHTTP(refreshW, refreshReq)
if refreshW.Code != http.StatusOK {
t.Fatalf("Token refresh failed: %d: %s", refreshW.Code, refreshW.Body.String())
}
var refreshResp map[string]interface{}
if err := json.Unmarshal(refreshW.Body.Bytes(), &refreshResp); err != nil {
t.Fatalf("Failed to parse refresh response: %v", err)
}
if refreshResp["access_token"] != "access-123" {
t.Errorf("Expected access_token 'access-123', got '%v'", refreshResp["access_token"])
}
}
+6
View File
@@ -457,19 +457,25 @@ func (s *Server) HandleMargeUpdatePreset(w http.ResponseWriter, r *http.Request)
presetNumber, err := strconv.Atoi(presetNumberStr)
if err != nil {
log.Printf("[Marge] Invalid preset number: %s", presetNumberStr)
http.Error(w, "Invalid preset number", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("[Marge] Failed to read body: %v", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
data, err := marge.UpdatePreset(s.ds, account, device, presetNumber, body)
if err != nil {
log.Printf("[Marge] UpdatePreset failed for account=%s, device=%s, preset=%d: %v", account, device, presetNumber, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+1 -1
View File
@@ -515,7 +515,7 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) {
"<source id=\"10004\" type=\"Audio\"",
"<source id=\"10003\" type=\"Audio\"",
"<source id=\"10002\" type=\"Audio\"",
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\">",
"<source id=\"10001\" type=\"Audio\" displayName=\"AUX IN\" secretType=\"token\">",
"displayName=\"\"", // for the other sources
}
+9 -2
View File
@@ -227,7 +227,14 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
log.Printf("[Spotify Bridge] Registering Spotify user %s in Marge for account %s", acc.UserID, accountID)
// 1. Register in Marge (updates configuredsources.xml for all devices in the account)
_, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", acc.AccessToken, "token_version_3", acc.DisplayName)
// We use the BoseSecret as the credential instead of the AccessToken
credential := acc.BoseSecret
if credential == "" {
// Fallback to AccessToken if BoseSecret is not available (for old accounts)
credential = acc.AccessToken
}
_, err := marge.AddSource(s.ds, accountID, acc.UserID, "15", credential, "token_version_3", acc.DisplayName)
if err != nil {
log.Printf("[Spotify Bridge] Failed to register source in Marge: %v", err)
continue
@@ -240,7 +247,7 @@ func (s *Server) bridgeSpotifyToMarge(accountID string) {
continue
}
creds := models.NewSpotifyOAuthCredentials(acc.UserID, acc.AccessToken, acc.DisplayName)
creds := models.NewSpotifyOAuthCredentials(acc.UserID, credential, acc.DisplayName)
for i := range allDevices {
dev := &allDevices[i]
+55 -5
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/go-chi/chi/v5"
)
@@ -114,12 +115,61 @@ func (s *Server) HandleBoseSpotifyToken(w http.ResponseWriter, r *http.Request)
}
// 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)
// However, if the request provides a "secret" (which we use as our Bose surrogate token),
// we should use that to find the specific account.
var (
account *spotify.Account
accessToken string
userID string
)
return
// Spotify registration/refresh often passes the secret in the body as "refresh_token"
// or in the registration flow as "code".
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)
secret := tokenReq.RefreshToken
if secret == "" {
secret = tokenReq.Code
}
if secret != "" {
if acc, ok := svc.GetAccountBySecret(secret); ok {
account = acc
log.Printf("[Spotify Proxy] Found account for secret %s: %s", secret, acc.UserID)
}
}
if account != nil {
if err := svc.RefreshAccessToken(account); err != nil {
log.Printf("[Spotify Proxy] Failed to refresh token for %s: %v. Falling back to upstream", account.UserID, err)
s.HandleBoseProxy(w, r)
return
}
accessToken = account.AccessToken
} else {
// Fallback to first account for backward compatibility or when secret is missing
var err error
accessToken, userID, 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
}
log.Printf("[Spotify Proxy] Using default account %s", userID)
}
// Format response as expected by Bose firmware.
+16 -1
View File
@@ -751,6 +751,7 @@ func mapToFullResponseSource(s models.ConfiguredSource) models.FullResponseSourc
SourceSettings: "",
UpdatedOn: s.UpdatedOn,
Username: s.Username,
SecretType: s.SecretType,
}
mapToFullResponseCredential(s, &fullSource)
@@ -1112,7 +1113,11 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
var matchingSrc *models.ConfiguredSource
log.Printf("[Marge] Searching for source matching ID=%s in %d sources", newPresetElem.SourceID, len(sources))
for i := range sources {
log.Printf("[Marge] Source[%d]: ID=%s, Type=%s, SourceKeyType=%s, SourceKeyAccount=%s", i, sources[i].ID, sources[i].Type, sources[i].SourceKeyType, sources[i].SourceKeyAccount)
if sources[i].ID == newPresetElem.SourceID {
matchingSrc = &sources[i]
break
@@ -1120,7 +1125,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
}
if matchingSrc == nil {
if newPresetElem.SourceID == "INTERNET_RADIO" || newPresetElem.SourceID == "TUNEIN" {
if newPresetElem.SourceID == "INTERNET_RADIO" || newPresetElem.SourceID == "TUNEIN" || newPresetElem.SourceID == "SPOTIFY" {
// Find by SourceKeyType instead of ID if it's a default source
for i := range sources {
if sources[i].SourceKeyType == newPresetElem.SourceID {
@@ -1180,6 +1185,7 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
Value string `xml:",chardata"`
} `xml:"credential"`
}{
ID: matchingSrc.ID,
SourceName: newPresetElem.Name,
},
})
@@ -1238,6 +1244,15 @@ func syncMatchingSource(matchingSrc *models.ConfiguredSource, input recentInput)
if matchingSrc == nil {
return
}
if input.Source.ID != "" {
matchingSrc.ID = input.Source.ID
}
if input.Source.Type != "" {
matchingSrc.Type = input.Source.Type
}
// Ensure we use the latest secret from the input if it was just learned/updated
if input.Source.Credential.Value != "" {
matchingSrc.Secret = input.Source.Credential.Value
+1 -1
View File
@@ -244,7 +244,7 @@ func TestAccountFullToXML_Structure(t *testing.T) {
}
// Global Sources
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user">`) {
if !strings.Contains(xmlStr, `<source id="10863533" type="Audio" displayName="test-user" secretType="token_version_3">`) {
t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr)
}
if !strings.Contains(xmlStr, `<name>test-user</name>`) {
+32
View File
@@ -3,6 +3,8 @@
package spotify
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -35,6 +37,7 @@ type Account struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
BoseSecret string `json:"bose_secret,omitempty"`
}
// Service manages Spotify OAuth flow and token lifecycle.
@@ -121,6 +124,9 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
displayName, _ := profile["display_name"].(string)
email, _ := profile["email"].(string)
// Generate a Bose surrogate secret (represented as a 32-char hex string)
boseSecret := s.generateBoseSecret()
account := &Account{
UserID: userID,
DisplayName: displayName,
@@ -128,6 +134,7 @@ func (s *Service) ExchangeCodeAndStore(code string) error {
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
BoseSecret: boseSecret,
}
s.mu.Lock()
@@ -320,6 +327,7 @@ func (s *Service) GetAccounts() []Account {
DisplayName: a.DisplayName,
Email: a.Email,
ExpiresAt: a.ExpiresAt,
BoseSecret: a.BoseSecret,
// AccessToken and RefreshToken deliberately omitted
})
}
@@ -327,6 +335,30 @@ func (s *Service) GetAccounts() []Account {
return result
}
// GetAccountBySecret retrieves a Spotify 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
}
func (s *Service) generateBoseSecret() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback to timestamp-based if RNG fails
return fmt.Sprintf("bs-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// ResolveEntity resolves a Spotify URI to a name and image URL.
func (s *Service) ResolveEntity(uri string) (name, imageURL string, err error) {
entityType, entityID, err := parseSpotifyURI(uri)
+10 -10
View File
@@ -17,23 +17,23 @@ User-Agent: Bose_Lisa/27.0.6
client.assert(presets !== null, "Response body does not contain <presets>");
const presetList = doc.getElementsByTagName("preset");
// Based on the flow: set_preset_6, get_presets, delete_preset_6, set_preset_5
// Based on the flow: spotify_full_flow, set_preset_6, get_presets, delete_preset_6, set_preset_5
// The service is fresh, so only what we set is there.
client.assert(presetList.length === 1, "Response body should contain exactly one <preset>, but found " + presetList.length);
client.assert(presetList.length >= 2, "Response body should contain at least one <preset>, but found " + presetList.length);
const firstPreset = presetList[0];
client.assert(firstPreset.getAttribute("buttonNumber") === "6", "First preset should have buttonNumber=\"6\"");
const secondPreset = presetList[1];
client.assert(secondPreset.getAttribute("buttonNumber") === "6", "Second preset should have buttonNumber=\"6\"");
// Check <name>
const name = firstPreset.getElementsByTagName("name")[0];
const name = secondPreset.getElementsByTagName("name")[0];
client.assert(name.textContent === "SMOOTH JAZZ", "Preset name should be 'SMOOTH JAZZ'");
// Check <location>
const location = firstPreset.getElementsByTagName("location")[0];
const location = secondPreset.getElementsByTagName("location")[0];
client.assert(location.textContent === "/v1/playback/station/s166521", "Preset location mismatch");
// Check <source> and its attributes/children
const source = firstPreset.getElementsByTagName("source")[0];
const source = secondPreset.getElementsByTagName("source")[0];
client.assert(source !== null, "Preset should have a <source>");
// The source ID is set in HandleMargeUpdatePreset based on matching source, or in UpdatePreset.
// For TUNEIN it might be 10004 or similar in our mocks.
@@ -44,9 +44,9 @@ User-Agent: Bose_Lisa/27.0.6
client.assert(sourceproviderid.textContent === "25", "sourceproviderid should be '25'");
// Check <username> inside <preset> (not the one in <source> if present)
const presetUsernames = firstPreset.getElementsByTagName("username");
const presetUsernames = secondPreset.getElementsByTagName("username");
// In the reference, there's one in <source> (empty) and one in <preset> (SMOOTH JAZZ)
// Usually, getElementsByTagName on firstPreset returns all descendants.
// Usually, getElementsByTagName on secondPreset returns all descendants.
// Let's be careful about the index or use a more specific selector if possible,
// but here we can check the values.
let foundPresetUsername = false;
@@ -59,7 +59,7 @@ User-Agent: Bose_Lisa/27.0.6
client.assert(foundPresetUsername, "Preset should have <username>SMOOTH JAZZ</username>");
// Ensure <containerArt> is non-empty
const containerArt = firstPreset.getElementsByTagName("containerArt")[0];
const containerArt = secondPreset.getElementsByTagName("containerArt")[0];
client.assert(containerArt.textContent.startsWith("https://"), "containerArt should be a valid URL");
});
%}
@@ -0,0 +1,209 @@
### Spotify Full Flow: Registration, Preset, and Token Refresh
# 1. Initialize Spotify Flow
# @name Init Spotify Flow
POST {{host}}/mgmt/spotify/init
Authorization: Basic admin change_me!
###
# 2. Spotify Callback
# Simulates the user being redirected back from Spotify with a code
# This should also trigger the Bridge (Marge registration + Speaker notification)
# @name Spotify Callback
GET {{host}}/mgmt/spotify/callback?code=mock-auth-code&account={{accountId}}
###
# 3. Verify Spotify Account and extract Bose Secret
# @name Verify Accounts
GET {{host}}/mgmt/spotify/accounts
Authorization: Basic admin change_me!
> {%
client.test("Account exists and has Bose Secret", function() {
client.assert(response.body.accounts.length > 0, "No accounts found");
var found = false;
for (var i = 0; i < response.body.accounts.length; i++) {
if (response.body.accounts[i].user_id === "spotify-user-id") {
found = true;
client.assert(response.body.accounts[i].bose_secret !== undefined, "Bose Secret missing");
client.global.set("bose_secret", response.body.accounts[i].bose_secret);
break;
}
}
client.assert(found, "Account 'spotify-user-id' not found");
});
%}
###
# 4. Verify Marge Source Registration and extract Spotify Source ID
# @name Get Marge Sources
GET {{host}}/accounts/{{accountId}}/sources
> {%
client.test("Spotify source registered in Marge", function() {
const doc = response.body;
const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
function findSpotifySource(parent) {
const children = parent.childNodes;
if (!children) return null;
for (var i = 0; i < children.length; i++) {
const node = children[i];
if (node.nodeName && (node.nodeName.toLowerCase() === "source")) {
// Check sourceKey
const sourceproviderids = node.getElementsByTagName("sourceproviderid");
for (var j = 0; j < (sourceproviderids ? sourceproviderids.length : 0); j++) {
const sourceproviderid = sourceproviderids[j].textContent;
if (sourceproviderid == spotifySourceProviderID) {
return node;
}
}
}
// Recurse
const found = findSpotifySource(node);
if (found) return found;
}
return null;
}
const spotifySource = findSpotifySource(doc);
client.assert(spotifySource !== null, "Spotify source not found in Marge for 'spotify-user-id'");
const id = spotifySource.getAttribute("id");
client.assert(id !== undefined && id !== "", "Spotify source ID missing");
client.global.set("spotify_source_id", id);
const secretType = spotifySource.getAttribute("secretType");
client.assert(secretType === "token_version_3", "Wrong secret type in Marge: " + secretType);
});
%}
###
# 5. Add a Spotify Preset
# @name Update Preset
POST {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets/1
Content-Type: application/xml
<preset id="1">
<name>Test Track</name>
<sourceid>{{spotify_source_id}}</sourceid>
<contentItemType>track</contentItemType>
<location>spotify:track:123</location>
</preset>
> {%
client.test("Preset added successfully", function() {
client.assert(response.status === 200, "Failed to update preset");
});
%}
###
# 6. Verify Preset in Marge
# @name Get Presets
GET {{host}}/accounts/{{accountId}}/devices/{{deviceId}}/presets
> {%
client.test("Verify preset content", function() {
const doc = response.body;
const spotifySourceProviderID = client.variables.environment.get("spotifyProviderID");
function findSpotifyPreset(parent) {
const children = parent.childNodes;
if (!children) return null;
for (var i = 0; i < children.length; i++) {
const node = children[i];
// Handle both uppercase and lowercase tag names just in case
const nodeName = node.nodeName ? node.nodeName.toLowerCase() : "";
if (nodeName === "preset") {
// Check source provider inside the preset
const providers = node.getElementsByTagName("sourceproviderid");
for (var j = 0; j < (providers ? providers.length : 0); j++) {
if (providers[j].textContent === spotifySourceProviderID) {
return node;
}
}
}
// Recurse
const found = findSpotifyPreset(node);
if (found) return found;
}
return null;
}
const spotifyPreset = findSpotifyPreset(doc);
client.assert(spotifyPreset !== null, "Spotify preset not found in Marge for provider '" + spotifySourceProviderID + "'");
// Verify preset elements (flat structure)
function getTagContent(parent, tagName) {
const elements = parent.getElementsByTagName(tagName);
if (elements && elements.length > 0) return elements[0].textContent;
// Try case-insensitive fallback
const all = parent.getElementsByTagName("*");
for (var i = 0; i < all.length; i++) {
if (all[i].nodeName.toLowerCase() === tagName.toLowerCase()) return all[i].textContent;
}
return null;
}
client.assert(getTagContent(spotifyPreset, "contentItemType") === "track", "Wrong contentItemType");
client.assert(getTagContent(spotifyPreset, "location") === "spotify:track:123", "Wrong location");
client.assert(getTagContent(spotifyPreset, "name") === "Test Track", "Wrong name");
// Verify nested source info
const sources = spotifyPreset.getElementsByTagName("source");
var source = (sources && sources.length > 0) ? sources[0] : null;
if (!source) {
// Fallback search for 'Source'
const all = spotifyPreset.getElementsByTagName("*");
for (var i = 0; i < all.length; i++) {
if (all[i].nodeName.toLowerCase() === "source") {
source = all[i];
break;
}
}
}
client.assert(source !== null, "Missing nested source");
const credentials = source.getElementsByTagName("credential");
var credential = (credentials && credentials.length > 0) ? credentials[0] : null;
if (!credential) {
const all = source.getElementsByTagName("*");
for (var i = 0; i < all.length; i++) {
if (all[i].nodeName.toLowerCase() === "credential") {
credential = all[i];
break;
}
}
}
client.assert(credential !== null, "Missing source credential");
client.assert(credential.getAttribute("type") === "token_version_3", "Wrong credential type in preset");
client.assert(credential.textContent !== "", "Credential value (secret) missing");
});
%}
###
# 7. Token Refresh via Surrogate (Bose Secret)
# Simulates the speaker requesting a fresh access token
# @name Token Refresh
POST {{host}}/oauth/device/{{deviceId}}/music/musicprovider/15/token/cs3
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "{{bose_secret}}"
}
> {%
client.test("Token refreshed successfully", function() {
client.assert(response.status === 200, "Refresh failed");
client.assert(response.body.access_token !== undefined, "No access token in response");
client.assert(response.body.token_type === "Bearer", "Wrong token type");
});
%}