Skip to content

WIP: bootstrap Spotify Connect credentials via Zeroconf (fix INVALID_CREDENTIALS, #333) - #337

Closed
tobsch wants to merge 1 commit into
sonn-audio:devfrom
tobsch:feat/spotify-zeroconf-pairing
Closed

tobsch wants to merge 1 commit into
sonn-audio:devfrom
tobsch:feat/spotify-zeroconf-pairing

Conversation

@tobsch

@tobsch tobsch commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Since ~2026-08-10 Spotify rejects login5 authentication built from an OAuth access token whose client id isn't the desktop client (INVALID_CREDENTIALS; BAD_REQUEST if the account client id is passed), as prep for their “Soloist” launch (blog 2026-08-13). This kills loginWithAccessToken / startConnectDeviceWithToken, so every zone's Spotify Connect host fails to start and playback can't authenticate, while the Web API (browsing) still works.

Affects all librespot-based clients — librespot-org/librespot#1737, devgianlu/go-librespot#364. Tracked here in #333 (multiple users, all on beta.18 by coincidence of timing). Not a core regression: the credential/connect call args are unchanged vs beta.16 and node-librespot is the same 0.5.2.

A real Zeroconf/Connect handshake still authenticates (AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS). node-librespot already exports the primitive (startZeroconfLogin) — core just never called it.

Change

  • spotifyStreamingService.tspairLibrespotCredentialsViaZeroconf() wrapping startZeroconfLogin (mirrors generateLibrespotCredentialsFromOAuth), plus the addon-interface entry.
  • serviceAuth.tshandleSpotifyLibrespotZeroconf: POST /admin/api/spotify/librespot/zeroconf { accountId, deviceName?, timeoutMs? }. Advertises a temporary Connect device, resolves when the user selects it in the Spotify app, persists the returned blob as the account's librespotCredentials, and reinitialises the inputs. Mirrors the existing …/librespot/oauth handler.
  • spotifyHandlers.ts — registers the route.
  • spotifyInputService.ts — small correctness fix: credentialsPath: this.credentialsPayload ?? credPath fell back to credPath (a file path), which startNativeConnectHost then treats as a JSON blob → invalid credentials json. Changed to ?? '' so the intended path runs.

Verified end-to-end on hardware

Ran the equivalent of the new endpoint (startZeroconfLogin → pick device in Spotify app → persist blob). Result: INVALID_CREDENTIALS went from ~40/30s → 0 across 11 zones; connect hosts start; playback authenticates again. The returned blob is auth_type: 1 (stored credentials) — the path Spotify still accepts.

WIP

  • No admin-UI button yet — endpoint only. A “Pair Spotify” action in the UI (advertise, prompt “select this device in Spotify”, store the blob) is the natural follow-up; happy to add it.
  • One pairing yields an account-level blob usable by all zones (same as before Spotify's change).
  • Open question: whether to auto-trigger pairing when the token path returns INVALID_CREDENTIALS, vs. keep it an explicit user action (I lean explicit — it requires the user to tap the device in the app).

Have a reliable hardware repro; glad to iterate.

…D_CREDENTIALS)

Since ~2026-08-10 Spotify rejects login5 auth derived from an OAuth access token
whose client id is not the desktop client (INVALID_CREDENTIALS; BAD_REQUEST when
the account client id is passed), as prep for their "Soloist" launch. This kills
the `loginWithAccessToken` / `startConnectDeviceWithToken` path, so every zone's
Spotify Connect host fails to start and playback can't authenticate — while the
Web API (browsing) still works. Affects all librespot-based clients
(librespot-org/librespot#1737, devgianlu/go-librespot#364). Tracked in sonn-audio#333.

A real Zeroconf/Connect handshake still authenticates
(AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS). node-librespot already exposes the
primitive (`startZeroconfLogin`) but core never called it.

- spotifyStreamingService: `pairLibrespotCredentialsViaZeroconf()` wraps
  `startZeroconfLogin` (mirrors generateLibrespotCredentialsFromOAuth).
- serviceAuth: `handleSpotifyLibrespotZeroconf` — POST
  /admin/api/spotify/librespot/zeroconf { accountId, deviceName?, timeoutMs? }.
  Advertises a temporary Connect device; resolves when the user picks it in the
  Spotify app; persists the returned blob as the account's librespotCredentials
  and reinitialises the inputs.
- spotifyInputService: don't fall back to credPath (a file path) as the
  credentials blob — pass '' so the intended path is taken and librespot doesn't
  choke with "invalid credentials json".

Validated end-to-end on hardware: after Zeroconf pairing, INVALID_CREDENTIALS
went from ~40/30s to 0 across 11 zones and playback authenticates again.

WIP: no admin-UI button yet (endpoint only); a "Pair Spotify" action in the UI
is the natural follow-up. Pairing yields an account-level blob usable by all zones.
rudyberends added a commit that referenced this pull request Aug 15, 2026
…cepts

Since 2026-08-10 Spotify refuses any login built from an OAuth access token whose
client id is not the desktop one. That is every credential this server can produce
on its own: `loginWithAccessToken` mints from a token, `startConnectDeviceWithToken`
exchanges one internally, and both come out INVALID_CREDENTIALS. Browsing runs on the
Web API and is untouched, so an account looks healthy while no zone can authenticate —
which is exactly how #333 reads. The same week broke librespot and go-librespot too.

Nothing on that path can be repaired, so the recovery written for #333 could not land:
every route out of a refusal — re-mint, then fall back to the access token — goes
through the closed door. What still authenticates is a blob handed over by the Spotify
app during a real handshake, and node-librespot has exposed `startZeroconfLogin` all
along without anything here calling it.

So: advertise a plain Connect device, wait for someone to pick it, keep what comes back
as the account's credentials. Pairing runs as a job rather than on the request, because
it finishes when a person taps something and a browser will not hold a POST that long;
the screen polls and can say what to go and tap. One handshake per account at a time.

A refusal is now a verdict with an expiry rather than a life sentence. That was
survivable while minting could replace a retired blob, but it cannot, so a single
refusal — including a wrong one against a blob that works — retired that device for the
life of the process. And a new account blob clears every verdict recorded against the
old one: `credentialsForDevice` consults the refusal flag before falling back to the
account payload, so without that a paired account would keep logging in with nothing at
all, and pairing would report success while changing nothing until a restart.

Refs #333. Zeroconf primitive and endpoint shape from tobsch's #337.

Co-authored-by: Tobias Schlottke <tobias@saas.group>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants