Open kingosticks opened 1 year ago
I don't have any official Spotify hardware. So I did not have the chance to test this tool on a device sending tokenType":"authorization_code"
. But keep in mind that 4 different authentication methods are proposed. You can switch the authentication mechanisms with the --auth-type
flag followed by one of the 4 possibilities: reusable
, password
, default-token
or access-token
. Maybe one of them will let you connect on your Sonos Roam.
If it is not the case, you can try to intercept the traffic between the official client and your receiver. Or you can create a false receiver and try to connect with the official client to get the information it is sending. It would help to develop another authentication mechanisms if needed.
Yep, I'll give it a go. Although I suspect it'll need some extra code. Maybe we need to use the Hermes code
endpoint (which librespot doesn't (yet) expose).
Unfortunately, not much progress. I couldn't get my Android client to do the addUser
when using "tokenType": "authorization_code"
unless I also use "clientID": "9b377073ea334637b1406f329ce005de"
in my getInfo
response. This is the client ID my Sonos Roam was returning in its getInfo
response. I tried a bunch of other client IDs (keymaster, Android client, my own) but the Android client didn't seem to like them. Maybe there's a list of client IDs that are allowed to use this auth method.
When using the working clientID, the addUser
request I got was:
{
"action": "addUser",
"blob": "AQC_0P91EOZDXSVldrLKtt5l1NoEecy8lvNvUF_wPXnZ8iPd22wq4lSA3xk4_WKxl4bmIsqz_-jYngzxoPwaSK.......Q3g",
"clientKey": "",
"deviceId": "f2eb0c419582c0b6690fa9ad4999b1b758ad38a3",
"deviceName": "Pixel 6a",
"loginId": "a969e0be850f......dc38f265a725",
"tokenType": "authorization_code",
"userName": "kingosticks4",
"version": "2.7.1"
}
Maybe that's helpful to someone down the line who makes more progress.
I used mitm proxy to see what my desktop client was doing in response to "tokenType": "authorization_code"
. The client makes a POST request to https://accounts.spotify.com/api/token to exchange its (access?) token into an authorization_code
. It sets a bunch of params, one of which is audience
with the value taken from clientID
in my getInfo
response.
If I use the Sonos client ID, the client receives a valid response and it sends me the returned token in the blob
field of its subsequent addUser
request:
{
"access_token": "AQC6oYY86uub...........................AGbcbx47Kvw",
"expires_in": 600,
"issued_token_type": "urn:spotify:params:oauth:authorization_code",
"scope": "streaming",
"token_type": "Bearer"
}
But if I use any other client ID, the client gets the following and gives up:
{
"error": "invalid_target",
"error_description": "audience: not supported"
}
For librespot, we could probably hardcode the Sonos client ID in getInfo
responses for this token type. For the other side (something like this tool), we'd need to add support for converting credentials into an authorization_code. Either via HTTP (like my desktop client did) or maybe via Hermes (old way?) using the keymaster (hm://keymaster/code
???). I'm not sure I care enough to do either but I had fun investigating and maybe useful for someone else.
I also noticed that Spotify is usually making some checks on the clientID
. It is not possible to send something gibberish or a known clientID
. It would be refused.
Thanks for your research! Currently, I don't have time to code and test something. If you or anyone ever find the need for and the time, don't hesitate to make a PR!
No problem, I'll probably add some of this to the librespot docs at some point.
Just curious if anyone has made progress on handling the tokentype=authorization_code
type of requests?
It sounds like the client is exchanging an access token for an authorization_code token. If that is the case, isn't the access token bound to a specific clientId (Sonos constant of 9b377073ea334637b1406f329ce005de
) and clientSecret (unknown / not public knowledge) value? You would need access to the access token that was generated for that clientid, which is stored on the client itself. In other words, I don't think you could generate an access token for that clientId without knowing the clientSecret value, no?
No progress from my side. I don't have any official receiver, so I can't try anything. Sorry and good luck!
@TimotheeGerber Thanks for replying. I currently don't have any Sonos devices either, but am in the process of getting a Sonos Symphonisk device. Will keep you posted.
So, reading again what I wrote last year, I think I must have been confused...
The client makes a POST request to https://accounts.spotify.com/api/token to exchange its (access?) token into an authorization_code
I'm not sure what I was thinking here. You cannot exchange a short-lived access token for an authorization code, that makes no sense. Surely the client was grabbing a new access token to then pass on to the sonos device for it to use. On top of this there is sanity checking of the audience parameter, for some reason?
So in theory the player should be able to use the access token with the Spotify Web API. In my efforts at https://github.com/librespot-org/librespot/pull/1098 I had lots of problems using that access token. However, I was trying to do something more complicated than simply use it for Web API requests, it might work just fine for that. I hope that makes sense.
I guess my biggest question is what's in the Spotify Connect Zeroconf API addUser
blob for tokentype=authorization_code
?
I know the blob layout for the tokenType=default
(seems to work for tokenType=accesstoken
as well):
blob = bytearray()
write_int(0x49, blob) # 'I'
write_bytes(self.credentials.username, blob) # username
write_int(0x50, blob) # 'P'
write_int(self.credentials.auth_type, blob) # auth_type (0x00)
write_int(0x51, blob) # 'Q'
write_bytes(self.credentials.password, blob) # password
One would think it would be some sort of serialized token, instead of a formatted blob structure?
My only goal at this point is to "wake" the Sonos device up, so that it will be listed in the Spotify Connect active device list. The fact that it is implementing the Spotify Connect Zeroconf API endpoints (addUser
, resetUsers
, and getInfo
) tells me that it's possible.
I think the format is the same except password is replaced with access token. I vaguely remember dumping it out and the "access token" I got looked vaguely sensible. I think (I don't remember exactly) that's what I implemented in the PR I linked. I could try and resurrect this but time is more scarce these days.
@kingosticks Thanks for the info.
Regarding the access token, can you recall if:
https://accounts.spotify.com/api/token
to request / renew a Spotify authorization token.I would assume that it's a Spotify Webservices API access token, and am also assuming that it should have the streaming
scope (or whatever is returned by the getInfo
action response)?
I reviewed the PR changes, but did not see anything Sonos specific in that code. Note that I am not too familiar with librespot though, so no surprise there. I'm learning though!
It was a Spotify Web API token, my earlier post specifies the endpoint and the scope. Everything here is about Spotify, it just happens to be running on a Sonos device.
@kingosticks Making some progress on this. I have traced the authorization flow (using Fiddler) of the Spotify desktop client. It performs the following steps:
getInfo
request to retrieve Spotify Connect device details.subject_token
), and returns an authorization_code
token type (requested_token_type
). You weren't confused from what you wrote last year; it does appear to be converting the access token to an authorization_code token type. Note the audience
argument that contains the Sonos app id (9b377073ea334637b1406f329ce005de
) - I believe this is what allows the Sonos device to do what it needs to do to access Spotify resources on behalf of the user. The client_id
must also use the Spotify desktop app id value (65b708073fc0480ea92a077233ca87bd
), otherwise it returns a client_id not allowed
error for the exchange request.
?audience=9b377073ea334637b1406f329ce005de
&client_id=65b708073fc0480ea92a077233ca87bd
&grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:spotify:params:oauth:authorization_code
&resource=urn:spotify:resources:connect
&scope=streaming
&subject_token=BQBZuPAUW ...redacted
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
addUser
request to login the user to Spotify Connect on the Sonos device. The blob
parameter contains the authorization_code token returned by the previous token-exchange.Where I am stuck is the call to the Login5 endpoint. I need to find a way to use this endpoint with a Spotify userid and password (instead of a cached token), preferably in Python. The closest thing I could find is the spotify-login PHP code on GitHub, but not sure if that is what I need.
Am I even in the ballpark with this? Interested to hear your thoughts on it.
Thanks - Todd
I've actually been working on this again from a slightly different angle (desktop login) following Spotify's temporary breakage of user+password login, which we've since heard they intend to make permanent so librespot (and friends) need an alternative.
Spotify's desktop app login is currently as follows https://github.com/librespot-org/librespot/issues/1308#issuecomment-2258478905:
1. Oauth prompt opened in user's web browser at accounts.spotify.com for oauth flow, user completes and `code` is returned to app 2. Gets a `client-token` (can't remember how but we do this already) 3. Exchange `code` for short-lived access token with POST https://accounts.spotify.com/api/token (using `client-token` header), 4. Swap the temporary access token for stored credentials using Mercury `ClientResponseEncrypted` request of type `AUTHENTICATION_SPOTIFY_TOKEN` with our username and the `auth_data` is just the access token from step 3. Receives `APWelcome` response containing long-lived `reusable_auth_credentials`. 5. All future logins (Mercury and login5) are using reusable creds. e.g. POST https://login5.spotify.com/v3/login (using `client-token` header) sending `LoginRequest` using `stored_credential` `LoginRequest` method
And I've done a PR to support this oauth-style flow at https://github.com/librespot-org/librespot/pull/1309 which also documents some limitations I found when experimenting with session authentication using an access token.
User and password authentication with Login5 might still be possible in the future using the Android client-id and solving the hashcash challenges, that's not clear yet. That's what the Spotify Android app currently does but maybe they'll change that too.
So back to what we were trying to do here, I would expect that the Login5 call you see is using a "stored credentials" blob rather than an access token. I still think it's backwards to try and get an authorization code from an access token, so struggling to get my head around that but maybe, I've not looked again in detail at this flow again (yet).
He big take-away here is that user+password login is being deprecated by Spotify. So you'll need to factor that into your plans.
@kingosticks
Thanks for the info. I would be interested to learn how you get the client-token
value as that is definitely in use, based on the Spotify desktop client trace I did. Here's the (redacted) trace if you're interested.
Traced via Fiddler
To re-produce the Spotify Connect authorization flow:
==================================================================================================================
** Spotify Login5 request
- I think this login is based on an existing token that is refreshed (e.g. header value "client-token" supplied).
--- Request --------------------------------------------------------------------
POST https://login5.spotify.com/v3/login HTTP/1.1
Host: login5.spotify.com
Connection: keep-alive
Content-Length: 338
Pragma: no-cache
Cache-Control: no-cache, no-store, max-age=0
Content-Type: application/x-protobuf
User-Agent: Spotify/124300420 Win32_x86_64/0 (PC desktop)
client-token: AADEtsyn61nyFc72pfD9QmqeeL7ktrrm+UuSqJ4VDl06aecKKf ... redacted ... MwYZw==
Origin: https://login5.spotify.com
Accept-Language: en-Latn-US,en-US;q=0.9,en-Latn;q=0.8,en;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br, zstd
K
65b708073fc0480ea92a077233ca87bd'S-1-5-21-240303764-901663538-1355479652
31l77y2a ... redacted ... AQDwpox_h8mDuyQDk6bw8_jF0sgSjRpQjuRIS ... redacted ...
--- Response ------------------------------------------------------------------
HTTP/1.1 200 OK
cache-control: private, max-age=0
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
date: Fri, 09 Aug 2024 16:32:39 GMT
server: envoy
Via: HTTP/2 edgeproxy, 1.1 google
Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Length: 653
31l77y2a ... redacted ... BQDkEds_dluzkCwIi8C4P8l8uRv00kAGYhjy5g2r ... redacted ...
==================================================================================================================
** Spotify Connect Zeroconf "getInfo" request
--- Request --------------------------------------------------------------------
GET http://192.168.1.91:1400/spotifyzc?action=getInfo&version=2.7.1 HTTP/1.1
Content-Type: application/x-www-form-urlencoded
User-Agent: Spotify/124300420 Win32_x86_64/0 (PC desktop)
Host: 192.168.1.91:1400
Keep-Alive: 0
Accept-Encoding: gzip
Connection: keep-alive
--- Response ------------------------------------------------------------------
HTTP/1.1 200 OK
Content-length: 672
Content-type: application/json; charset=utf-8
Server: Linux UPnP/1.0 Sonos/79.1-54060 (ZPS33)
Connection: close
{
"status":101,
"statusString":"OK",
"spotifyError":0,
"version":"2.9.0",
"deviceID":"f87342b3ad455375317d5af8aabde64a28bd49d0",
"publicKey":"\/DJnNdfSnKIKZWqcJgvjUb1qeotdMIhSXy ... redacted ... ",
"deviceType":"SPEAKER",
"libraryVersion":"3.199.414-gea87b026",
"resolverVersion":"0",
"groupStatus":"NONE",
"tokenType":"authorization_code",
"clientID":"9b377073ea334637b1406f329ce005de",
"productID":1233,
"scope":"streaming",
"availability":"",
"supported_drm_media_formats":[{"drm":1,"formats":70}],
"supported_capabilities":3,
"modelDisplayName":"Bookshelf",
"brandDisplayName":"Sonos",
"remoteName":"Office"
}
==================================================================================================================
** token exchange request to https://accounts.spotify.com/api/token
- access token is exchanged for an authorization_code token.
- authorization_code token will be used as the blob parameter for the subsequent addUser request.
- where does the "client-token" header value come from?
--- Request --------------------------------------------------------------------
POST https://accounts.spotify.com/api/token HTTP/1.1
Host: accounts.spotify.com
Connection: keep-alive
Content-Length: 764
Content-Type: application/x-www-form-urlencoded
User-Agent: Spotify/124300420 Win32_x86_64/0 (PC desktop)
client-token: AADEtsyn61nyFc72pfD9QmqeeL7ktrrm+UuSqJ4VDl06aecKKf ... redacted ... MwYZw==
Origin: https://accounts.spotify.com
Accept-Language: en-Latn-US,en-US;q=0.9,en-Latn;q=0.8,en;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br, zstd
?audience=9b377073ea334637b1406f329ce005de
&client_id=65b708073fc0480ea92a077233ca87bd
&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&requested_token_type=urn%3Aspotify%3Aparams%3Aoauth%3Aauthorization_code
&resource=urn%3Aspotify%3Aresources%3Aconnect
&scope=streaming
&subject_token=BQBZuPAUW3M4WtjtAgyFIp3OvYQdgHQMbyvbtFPiP_7D ... redacted ...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
--- Response ------------------------------------------------------------------
HTTP/1.1 200 OK
date: Fri, 09 Aug 2024 01:04:09 GMT
content-type: application/json
vary: Accept-Encoding
set-cookie: __Host-device_id=AQDo3-gBF9TVpq ... redacted ... ;Version=1;Path=/;Max-Age=2147483647;Secure;HttpOnly;SameSite=Lax
set-cookie: sp_tr=false;Version=1;Domain=accounts.spotify.com;Path=/;Secure;SameSite=Lax
access-control-allow-origin: https://accounts.spotify.com
access-control-allow-headers: User-Agent, Keep-Alive, Content-Type, Authorization, client-token, spotify-installation-id, dpop
access-control-allow-credentials: true
access-control-allow-methods: OPTIONS, GET, POST, DELETE, PUT
access-control-expose-headers: dpop-nonce
sp-trace-id: 079f5b692962fe83
x-envoy-upstream-service-time: 21
server: envoy
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
Via: HTTP/2 edgeproxy, 1.1 google
Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Length: 290
{
"access_token":"AQCTctoTVKQNifAaGgQoG1MG1mEN3vkE0UAnxCnT969G-jrOi ... redacted ... ",
"expires_in":600,
"issued_token_type":"urn:spotify:params:oauth:authorization_code",
"scope":"streaming",
"token_type":"Bearer",
}
==================================================================================================================
** Spotify Connect Zeroconf "addUser" request
--- Request --------------------------------------------------------------------
POST http://192.168.1.91:1400/spotifyzc HTTP/1.1
Content-Length: 370
Content-Type: application/x-www-form-urlencoded
User-Agent: Spotify/124300420 Win32_x86_64/0 (PC desktop)
Host: 192.168.1.91:1400
Keep-Alive: 0
Accept-Encoding: gzip
Connection: keep-alive
?action=addUser
&blob=AQCTctoTVKQNifAaGgQoG1MG1mEN3vkE0UAnxCnT969G-jrOi ... redacted ...
&clientKey=
&deviceId=80da4987232671a83397682373f7ce21ea92f2e4
&deviceName=THL ... redacted ...
&loginId=54119f7882aec70 ... redacted ...
&tokenType=authorization_code
&userName=31l77y2a ... redacted ...
&version=2.7.1
--- Response ------------------------------------------------------------------
HTTP/1.1 200 OK
Content-length: 69
Content-type: application/json; charset=utf-8
Server: Linux UPnP/1.0 Sonos/79.1-54060 (ZPS33)
Connection: close
{
"status":101,
"statusString":"OK",
"spotifyError":0,
"version":"2.9.0"
}
==================================================================================================================
And here's an old example trace I have:
POST https://clienttoken.spotify.com/v1/clienttoken
[uint32] 1 1
[message] 2
[string] 2.1 1.2.14.1149.ga3ae422d
[string] 2.2 65b708073fc0480ea92a077233ca87bd
[message] 2.3
[message] 2.3.1
[message] 2.3.1.4
[uint32] 2.3.1.4.1 10
[uint32] 2.3.1.4.3 22621
[uint32] 2.3.1.4.4 2
[uint32] 2.3.1.4.6 9
[uint32] 2.3.1.4.7 332
[uint32] 2.3.1.4.8 34404
[string] 2.3.2 S-1-5-21-2654172357-4096885972-2531102750
Thank you very much @kingosticks (and all the librespot community that helped you) for your PR about OAuth flow! As the username/password flow is broken (at least for me), I switched to the OAuth flow by default in this tool too. It works well with my versions of librespot at least. Don't know about officially supported hardware, I still don't have one.
@thlucas1 It seems almost everything is here to implement the authorization_code
flow for Sonos. You can generate access token thanks to the new OAuth flow. Have a look to the AuthType::AccessToken
here to see how to use the information from the device into your next request to get your authorization_code
and send the correct addUser
POST request. Good luck!
@TimotheeGerber Thank you, I already had it working. There is an extra step for the user in my process to run a Python script to allow them to authorize the access request, but other than that it works great.
Thanks for reaching out.
I randomly dumped the getInfo response from my Sonos Roam and noticed it was sending
tokenType":"authorization_code"
along with aclientID
and all the usual stuff. I've not yet tried it with this tool to see what happens when I respond with an access token. Has anyone got this flow working?