Skip to content

Commit 0b6dcd1

Browse files
rudyberendstobsch
andcommitted
fix(spotify): let a person hand over the credentials Spotify still accepts
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>
1 parent 7ba06c3 commit 0b6dcd1

4 files changed

Lines changed: 294 additions & 22 deletions

File tree

src/adapters/content/providers/spotify/serviceAuth.ts

Lines changed: 177 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { consumePkceVerifier } from '@/adapters/content/providers/spotify/pkce';
99
import { resolveSpotifyClientId } from '@/adapters/content/providers/spotify/utils';
1010
import {
1111
generateLibrespotCredentialsFromOAuth,
12+
pairLibrespotCredentialsViaZeroconf,
1213
} from '@/adapters/inputs/spotify/spotifyStreamingService';
1314
import fsp from 'node:fs/promises';
1415
import path from 'node:path';
@@ -212,14 +213,7 @@ export async function handleSpotifyLibrespotOAuth(
212213
return;
213214
}
214215

215-
const cfg = configPort.getConfig();
216-
const account = cfg.content?.spotify?.accounts?.find(
217-
(acc) =>
218-
acc.id === accountId ||
219-
acc.user === accountId ||
220-
acc.email === accountId ||
221-
acc.spotifyId === accountId,
222-
);
216+
const account = findSpotifyAccount(configPort, accountId);
223217
if (!account) {
224218
res.writeHead(404, { 'Content-Type': 'application/json' });
225219
res.end(JSON.stringify({ error: 'account_not_found' }));
@@ -262,6 +256,180 @@ export async function handleSpotifyLibrespotOAuth(
262256
}
263257
}
264258

259+
/**
260+
* A pairing handshake in flight, per account.
261+
*
262+
* Pairing finishes when a person picks the device in the Spotify app, so it is held here rather than
263+
* on the request: a browser will not sit on a POST for two minutes, and the admin screen needs
264+
* something to poll while it tells the user what to go and tap.
265+
*/
266+
type PairingState = {
267+
state: 'pairing' | 'paired' | 'failed';
268+
deviceName: string;
269+
startedAt: number;
270+
expiresAt: number;
271+
username?: string;
272+
error?: string;
273+
};
274+
275+
const pairingByAccount = new Map<string, PairingState>();
276+
const PAIRING_RESULT_TTL_MS = 5 * 60 * 1000;
277+
278+
function pairingSnapshot(accountId: string): PairingState | null {
279+
const entry = pairingByAccount.get(accountId);
280+
if (!entry) {
281+
return null;
282+
}
283+
// A settled result is worth keeping only long enough for the screen that asked to read it.
284+
if (entry.state !== 'pairing' && Date.now() - entry.expiresAt > PAIRING_RESULT_TTL_MS) {
285+
pairingByAccount.delete(accountId);
286+
return null;
287+
}
288+
if (entry.state === 'pairing' && Date.now() > entry.expiresAt) {
289+
entry.state = 'failed';
290+
entry.error = 'timed_out';
291+
}
292+
return entry;
293+
}
294+
295+
function findSpotifyAccount(
296+
configPort: ConfigPort,
297+
accountId: string,
298+
): SpotifyAccountConfig | undefined {
299+
return configPort
300+
.getConfig()
301+
.content?.spotify?.accounts?.find(
302+
(acc) =>
303+
acc.id === accountId ||
304+
acc.user === accountId ||
305+
acc.email === accountId ||
306+
acc.spotifyId === accountId,
307+
);
308+
}
309+
310+
/**
311+
* Pair an account by handshake, the only login Spotify still accepts (#333).
312+
*
313+
* POST /admin/api/spotify/librespot/zeroconf { accountId, deviceName?, timeoutMs? }
314+
* Starts advertising and returns immediately with the device name to show the user.
315+
* GET /admin/api/spotify/librespot/zeroconf?accountId=<id>
316+
* Reports how it is going: pairing | paired | failed.
317+
*
318+
* One handshake per account at a time — a second advertisement for the same account would just
319+
* compete with the first for the same pick.
320+
*/
321+
export async function handleSpotifyLibrespotZeroconf(
322+
req: IncomingMessage,
323+
res: ServerResponse,
324+
configPort: ConfigPort,
325+
spotifyInputService: SpotifyInputService,
326+
): Promise<void> {
327+
const json = (status: number, body: unknown): void => {
328+
res.writeHead(status, { 'Content-Type': 'application/json' });
329+
res.end(JSON.stringify(body));
330+
};
331+
332+
if (req.method === 'GET') {
333+
const { searchParams } = new URL(req.url ?? '', 'http://localhost');
334+
const accountId = (searchParams.get('accountId') || '').trim();
335+
if (!accountId) {
336+
return json(400, { error: 'missing_account' });
337+
}
338+
const entry = pairingSnapshot(accountId);
339+
if (!entry) {
340+
return json(200, { ok: true, state: 'idle' });
341+
}
342+
return json(200, {
343+
ok: true,
344+
state: entry.state,
345+
deviceName: entry.deviceName,
346+
expiresAt: entry.expiresAt,
347+
username: entry.username,
348+
error: entry.error,
349+
});
350+
}
351+
352+
if (req.method !== 'POST') {
353+
return json(405, { error: 'method_not_allowed' });
354+
}
355+
356+
const body = (await readJsonBody(req)) as
357+
| { accountId?: string; deviceName?: string; timeoutMs?: number }
358+
| null;
359+
const accountId = (body?.accountId || '').trim();
360+
if (!accountId) {
361+
return json(400, { error: 'missing_account' });
362+
}
363+
364+
const account = findSpotifyAccount(configPort, accountId);
365+
if (!account) {
366+
return json(404, { error: 'account_not_found' });
367+
}
368+
369+
const existing = pairingSnapshot(accountId);
370+
if (existing?.state === 'pairing') {
371+
return json(200, {
372+
ok: true,
373+
state: 'pairing',
374+
deviceName: existing.deviceName,
375+
expiresAt: existing.expiresAt,
376+
alreadyRunning: true,
377+
});
378+
}
379+
380+
const deviceName = (body?.deviceName || '').trim() || 'Sonn (pairing)';
381+
const timeoutMs =
382+
typeof body?.timeoutMs === 'number' && Number.isFinite(body.timeoutMs)
383+
? Math.max(30_000, Math.min(300_000, body.timeoutMs))
384+
: 120_000;
385+
// librespot device ids are 40-hex; derive one from the account so repeat pairings reuse it
386+
// instead of leaving a trail of one-off devices in the user's Spotify app.
387+
const deviceId = crypto.createHash('sha1').update(`pair:${accountId}`).digest('hex');
388+
389+
const entry: PairingState = {
390+
state: 'pairing',
391+
deviceName,
392+
startedAt: Date.now(),
393+
expiresAt: Date.now() + timeoutMs,
394+
};
395+
pairingByAccount.set(accountId, entry);
396+
log.info('spotify zeroconf pairing started', { accountId, deviceName, timeoutMs });
397+
398+
void (async () => {
399+
try {
400+
const result = await pairLibrespotCredentialsViaZeroconf({
401+
deviceId,
402+
name: deviceName,
403+
timeoutMs,
404+
});
405+
if (!result) {
406+
entry.state = 'failed';
407+
entry.error = 'no_credentials';
408+
log.warn('spotify zeroconf pairing produced no credentials', { accountId });
409+
return;
410+
}
411+
let parsed: string | Record<string, unknown> = result.credentials;
412+
try {
413+
parsed = JSON.parse(result.credentials);
414+
} catch {
415+
/* keep string */
416+
}
417+
await pushLibrespotCredentials(spotifyInputService, accountId, parsed);
418+
entry.state = 'paired';
419+
entry.username = result.username;
420+
log.info('spotify zeroconf pairing stored', { accountId, username: result.username });
421+
reinitializeSpotifyInputs(configPort, spotifyInputService, 'zeroconf_paired', accountId);
422+
} catch (error) {
423+
const message = error instanceof Error ? error.message : String(error);
424+
entry.state = 'failed';
425+
entry.error = message;
426+
log.warn('spotify zeroconf pairing failed', { accountId, message });
427+
}
428+
})();
429+
430+
return json(202, { ok: true, state: 'pairing', deviceName, expiresAt: entry.expiresAt });
431+
}
432+
265433
/**
266434
* Export existing librespot credentials for an account.
267435
* Request (GET): /admin/api/spotify/librespot/credentials?accountId=<id>
@@ -284,14 +452,7 @@ export async function handleSpotifyLibrespotExport(
284452
res.end(JSON.stringify({ error: 'missing_account' }));
285453
return;
286454
}
287-
const cfg = configPort.getConfig();
288-
const account = cfg.content?.spotify?.accounts?.find(
289-
(acc) =>
290-
acc.id === accountId ||
291-
acc.user === accountId ||
292-
acc.email === accountId ||
293-
acc.spotifyId === accountId,
294-
);
455+
const account = findSpotifyAccount(configPort, accountId);
295456
if (!account) {
296457
res.writeHead(404, { 'Content-Type': 'application/json' });
297458
res.end(JSON.stringify({ error: 'account_not_found' }));

src/adapters/http/adminApi/spotify/spotifyHandlers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
deleteSpotifyAccount,
1414
handleSpotifyLibrespotExport,
1515
handleSpotifyLibrespotOAuth,
16+
handleSpotifyLibrespotZeroconf,
1617
handleSpotifyOAuthCallback,
1718
} from '@/adapters/content/providers/spotify/serviceAuth';
1819
import type { Route } from '@/adapters/http/adminApi/routeTypes';
@@ -64,6 +65,20 @@ export function buildSpotifyRoutes(deps: SpotifyHandlerDeps): Route[] {
6465
deps.spotifyManagerProvider,
6566
),
6667
},
68+
{
69+
// Start a pairing handshake — the only login Spotify still accepts (#333). Returns at once;
70+
// the GET below reports whether the user has picked the device yet.
71+
method: 'POST',
72+
pattern: /^\/spotify\/librespot\/zeroconf$/,
73+
handler: async (req, res) =>
74+
handleSpotifyLibrespotZeroconf(req, res, deps.configPort, deps.spotifyInputService),
75+
},
76+
{
77+
method: 'GET',
78+
pattern: /^\/spotify\/librespot\/zeroconf$/,
79+
handler: async (req, res) =>
80+
handleSpotifyLibrespotZeroconf(req, res, deps.configPort, deps.spotifyInputService),
81+
},
6782
{
6883
// Anchored and GET-only: the pattern used to be an unanchored prefix with no
6984
// method, so it also claimed anything beginning with this path.

src/adapters/inputs/spotify/spotifyInputService.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,17 @@ class SpotifyConnectInstance {
118118
* survives a rejection is per device, which is what this holds. Keyed `<accountId>::<deviceId>`.
119119
*/
120120
static deviceCredentials = new Map<string, string>();
121-
/** Devices whose account-level blob Spotify has already refused; never replayed again. */
122-
static rejectedCredentials = new Set<string>();
121+
/**
122+
* When Spotify last refused this device's blob, keyed the same way.
123+
*
124+
* A verdict, not a life sentence. It used to be a Set cleared only by a successful re-mint, which
125+
* was survivable while minting could succeed. Since Spotify closed the access-token login path it
126+
* cannot, so a single refusal — including a spurious one on a blob that is actually good — retired
127+
* that device's credentials for the rest of the process. It expires now, so the worst a wrong
128+
* verdict costs is one retry per {@link rejectionTtlMs}.
129+
*/
130+
static rejectedCredentials = new Map<string, number>();
131+
static readonly rejectionTtlMs = 30 * 60 * 1000;
123132
/** Last mint attempt per device, so a refusal that survives minting cannot spin. */
124133
static lastRemintAt = new Map<string, number>();
125134
static readonly remintCooldownMs = 10 * 60 * 1000;
@@ -232,7 +241,7 @@ class SpotifyConnectInstance {
232241
this.log.warn('spotify connect host start failed', { zoneId: this.zoneId, message });
233242
return null;
234243
});
235-
if (!native && SpotifyConnectInstance.rejectedCredentials.has(this.deviceCredentialKey(deviceId))) {
244+
if (!native && SpotifyConnectInstance.isRejected(this.deviceCredentialKey(deviceId))) {
236245
// Login was refused rather than unreachable: mint a blob for this device and try once more,
237246
// instead of handing the same refused one back on every scheduled restart.
238247
const minted = await this.mintCredentialsForDevice(deviceId, 'connect_login_refused');
@@ -405,7 +414,47 @@ class SpotifyConnectInstance {
405414
private markCredentialsRejected(deviceId: string): void {
406415
const key = this.deviceCredentialKey(deviceId);
407416
SpotifyConnectInstance.deviceCredentials.delete(key);
408-
SpotifyConnectInstance.rejectedCredentials.add(key);
417+
SpotifyConnectInstance.rejectedCredentials.set(key, Date.now());
418+
}
419+
420+
/** Whether this device's blob is currently under a refusal verdict that has not yet expired. */
421+
static isRejected(key: string): boolean {
422+
const at = SpotifyConnectInstance.rejectedCredentials.get(key);
423+
if (at === undefined) {
424+
return false;
425+
}
426+
if (Date.now() - at < SpotifyConnectInstance.rejectionTtlMs) {
427+
return true;
428+
}
429+
SpotifyConnectInstance.rejectedCredentials.delete(key);
430+
return false;
431+
}
432+
433+
/**
434+
* Drop every per-device verdict held for an account.
435+
*
436+
* Called when a new account-level blob arrives. Without this a fresh blob is unreachable:
437+
* `credentialsForDevice` consults the refusal flag before falling back to the account payload, so
438+
* a zone that had already been refused would keep logging in with nothing at all — pairing would
439+
* report success and change nothing until a restart.
440+
*/
441+
static clearVerdictsForAccount(accountId: string): void {
442+
const prefix = `${accountId}::`;
443+
for (const key of [...SpotifyConnectInstance.rejectedCredentials.keys()]) {
444+
if (key.startsWith(prefix)) {
445+
SpotifyConnectInstance.rejectedCredentials.delete(key);
446+
}
447+
}
448+
for (const key of [...SpotifyConnectInstance.deviceCredentials.keys()]) {
449+
if (key.startsWith(prefix)) {
450+
SpotifyConnectInstance.deviceCredentials.delete(key);
451+
}
452+
}
453+
for (const key of [...SpotifyConnectInstance.lastRemintAt.keys()]) {
454+
if (key.startsWith(prefix)) {
455+
SpotifyConnectInstance.lastRemintAt.delete(key);
456+
}
457+
}
409458
}
410459

411460
/**
@@ -418,7 +467,7 @@ class SpotifyConnectInstance {
418467
if (proven) {
419468
return proven;
420469
}
421-
if (SpotifyConnectInstance.rejectedCredentials.has(key)) {
470+
if (SpotifyConnectInstance.isRejected(key)) {
422471
return null;
423472
}
424473
return this.credentialsPayload;
@@ -440,7 +489,7 @@ class SpotifyConnectInstance {
440489
}
441490
SpotifyConnectInstance.lastRemintAt.set(key, Date.now());
442491
// Mark refused up front: if the mint fails, the seed must not be replayed on the next attempt.
443-
SpotifyConnectInstance.rejectedCredentials.add(key);
492+
SpotifyConnectInstance.rejectedCredentials.set(key, Date.now());
444493
SpotifyConnectInstance.deviceCredentials.delete(key);
445494

446495
let accessToken: string | null | undefined;
@@ -1764,6 +1813,9 @@ export class SpotifyInputService {
17641813
const serialized =
17651814
typeof credentials === 'string' ? credentials : JSON.stringify(credentials, null, 2);
17661815
SpotifyConnectInstance.accountCredentials.set(accountId, serialized);
1816+
// A new blob overrules every verdict recorded against the old one. Without this the zones that
1817+
// had already been refused would ignore the one credential that now works.
1818+
SpotifyConnectInstance.clearVerdictsForAccount(accountId);
17671819
await bestEffort(
17681820
() =>
17691821
this.configPort.updateConfig((cfg) => {

0 commit comments

Comments
 (0)