diff --git a/Makefile b/Makefile index 7e74813..c503166 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/docs/reference/spotify-account-addition.md b/docs/reference/spotify-account-addition.md index 8f346ca..fcfdb8d 100644 --- a/docs/reference/spotify-account-addition.md +++ b/docs/reference/spotify-account-addition.md @@ -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 | diff --git a/pkg/models/models.go b/pkg/models/models.go index 7cab1b8..a6f47c8 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -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. diff --git a/pkg/service/handlers/bridge_test.go b/pkg/service/handlers/bridge_test.go index e240895..b8a98b8 100644 --- a/pkg/service/handlers/bridge_test.go +++ b/pkg/service/handlers/bridge_test.go @@ -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"]) + } } diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index a563af2..6da5fbb 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -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 } diff --git a/pkg/service/handlers/handlers_marge_test.go b/pkg/service/handlers/handlers_marge_test.go index adf346e..7423d9c 100644 --- a/pkg/service/handlers/handlers_marge_test.go +++ b/pkg/service/handlers/handlers_marge_test.go @@ -515,7 +515,7 @@ func TestMargeAccountSourcesNoDevices(t *testing.T) { "", + "", "displayName=\"\"", // for the other sources } diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go index d5ccac4..912243a 100644 --- a/pkg/service/handlers/handlers_mgmt.go +++ b/pkg/service/handlers/handlers_mgmt.go @@ -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] diff --git a/pkg/service/handlers/handlers_oauth.go b/pkg/service/handlers/handlers_oauth.go index ce8c296..821fd4b 100644 --- a/pkg/service/handlers/handlers_oauth.go +++ b/pkg/service/handlers/handlers_oauth.go @@ -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. diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index eda6c4a..ae6a47e 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -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 diff --git a/pkg/service/marge/marge_test.go b/pkg/service/marge/marge_test.go index 1c40e8c..9852896 100644 --- a/pkg/service/marge/marge_test.go +++ b/pkg/service/marge/marge_test.go @@ -244,7 +244,7 @@ func TestAccountFullToXML_Structure(t *testing.T) { } // Global Sources - if !strings.Contains(xmlStr, ``) { + if !strings.Contains(xmlStr, ``) { t.Errorf("Expected source tag with displayName attribute, got %s", xmlStr) } if !strings.Contains(xmlStr, `test-user`) { diff --git a/pkg/service/spotify/service.go b/pkg/service/spotify/service.go index 44e040e..d4caff3 100644 --- a/pkg/service/spotify/service.go +++ b/pkg/service/spotify/service.go @@ -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) diff --git a/tests/integration/http-client/get_presets.http b/tests/integration/http-client/get_presets.http index 900bd93..3d3a402 100644 --- a/tests/integration/http-client/get_presets.http +++ b/tests/integration/http-client/get_presets.http @@ -17,23 +17,23 @@ User-Agent: Bose_Lisa/27.0.6 client.assert(presets !== null, "Response body does not contain "); 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 , but found " + presetList.length); + client.assert(presetList.length >= 2, "Response body should contain at least one , 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 - 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 - const location = firstPreset.getElementsByTagName("location")[0]; + const location = secondPreset.getElementsByTagName("location")[0]; client.assert(location.textContent === "/v1/playback/station/s166521", "Preset location mismatch"); // Check and its attributes/children - const source = firstPreset.getElementsByTagName("source")[0]; + const source = secondPreset.getElementsByTagName("source")[0]; client.assert(source !== null, "Preset should have a "); // 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 inside (not the one in if present) - const presetUsernames = firstPreset.getElementsByTagName("username"); + const presetUsernames = secondPreset.getElementsByTagName("username"); // In the reference, there's one in (empty) and one in (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 SMOOTH JAZZ"); // Ensure 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"); }); %} diff --git a/tests/integration/http-client/spotify_full_flow.http b/tests/integration/http-client/spotify_full_flow.http new file mode 100644 index 0000000..d723947 --- /dev/null +++ b/tests/integration/http-client/spotify_full_flow.http @@ -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 + + + Test Track + {{spotify_source_id}} + track + spotify:track:123 + + +> {% + 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"); + }); + %}