From fbb00baa113799019072d407a84a6e49bcb8608c Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 10 May 2026 23:12:29 +0200 Subject: [PATCH] feat: TOTP-based anonymous Spotify token (no developer credentials needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamically extracts Spotify's TOTP secret from their web-player JS bundle, computes a 6-digit HMAC-SHA1 code synced to open.spotify.com/api/server-time, and requests a token from open.spotify.com/api/token — completely bypassing the rate-limited Client Credentials token. Client Credentials kept as fallback. Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 168 +++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 61 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 465a270..917a67c 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -111,93 +111,139 @@ function _fetchTimeout(url, opts = {}, ms = 10000) { }); } -let _spTokenCache = null; +let _spTokenCache = null; let _spTokenExpiry = 0; +let _spSecretCache = null; +let _spSecretExp = 0; + +// Dynamically extract Spotify's TOTP secret from their web-player JS bundle. +async function _fetchSpotifySecret() { + if (_spSecretCache && Date.now() < _spSecretExp) return _spSecretCache; + + const htmlRes = await _fetchTimeout('https://open.spotify.com/', { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'identity', + }, + }, 20000); + if (!htmlRes.ok) throw new Error(`open.spotify.com ${htmlRes.status}`); + const html = await htmlRes.text(); + + // Collect JS bundle URLs referenced by the main page + const seen = new Set(); + const jsUrls = []; + const srcRe = /src="((?:https?:\/\/[^"]*|\/[^"]*?)\.js(?:\?[^"]*)?)"/g; + let m; + while ((m = srcRe.exec(html)) !== null) { + const url = m[1].startsWith('http') ? m[1] : 'https://open.spotify.com' + m[1]; + if (!seen.has(url)) { seen.add(url); jsUrls.push(url); } + } + + // Search each bundle for {secret:"...",version:N} + for (const url of jsUrls.slice(0, 15)) { + try { + const r = await _fetchTimeout(url, { + headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*', 'Accept-Encoding': 'identity' }, + }, 8000); + if (!r.ok) continue; + const js = await r.text(); + const sm = js.match(/\{secret:\s*["']([^"']+)["']\s*,\s*version:\s*(\d+)\s*\}/); + if (sm) { + const secret = [...sm[1]].map(c => c.charCodeAt(0)); + const version = parseInt(sm[2], 10); + _spSecretCache = { secret, version }; + _spSecretExp = Date.now() + 60 * 60 * 1000; // cache 1h + console.log(`[spotify] TOTP secret v${version} found`); + return _spSecretCache; + } + } catch { /* try next bundle */ } + } + throw new Error('Spotify TOTP-Secret nicht gefunden'); +} + +function _generateTotp(secretArray, serverTimestamp) { + const crypto = require('crypto'); + // XOR transform each byte with ((index % 33) + 9) + const transformed = secretArray.map((b, i) => b ^ ((i % 33) + 9)); + const secretBytes = Buffer.from(Buffer.from(transformed.join(''), 'utf8').toString('hex'), 'hex'); + const counter = Math.floor(serverTimestamp / 30); + const counterBuf = Buffer.alloc(8); + counterBuf.writeBigUInt64BE(BigInt(counter)); + const digest = crypto.createHmac('sha1', secretBytes).update(counterBuf).digest(); + const offset = digest[digest.length - 1] & 0xf; + const code = ( + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff) + ) % 1_000_000; + return code.toString().padStart(6, '0'); +} async function fetchSpotifyToken() { if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache; - const { randomUUID } = require('crypto'); - - // Approach 1: anonymous web-player token via clienttoken.spotify.com - // Uses Spotify's own web-player client ID — separate rate-limit bucket from developer apps. + // Approach 1: TOTP-based anonymous token — no developer credentials needed, + // uses open.spotify.com/api/token with a computed 6-digit code. try { - const ctRes = await _fetchTimeout('https://clienttoken.spotify.com/v1/clienttoken', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_data: { - client_version: '1.2.52.442.g276ab0bb', - client_id: 'd8a5ed958d274c2e8ee717e6a4b0971d', - js_sdk_data: { - device_brand: 'unknown', device_model: 'unknown', - os: 'windows', os_version: 'NT 10.0', - device_id: randomUUID(), device_type: 'computer', - }, - }, - }), + const { secret, version } = await _fetchSpotifySecret(); + + const stRes = await _fetchTimeout('https://open.spotify.com/api/server-time', { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://open.spotify.com/', + }, }); - if (ctRes.ok) { - const ct = await ctRes.json(); - const clientToken = ct.granted_token?.token; - if (clientToken) { - const atRes = await _fetchTimeout( - 'https://open.spotify.com/get_access_token?reason=transport&productType=web_player', - { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Cookie': `sp_t=${randomUUID()}`, - 'Client-Token': clientToken, - 'Referer': 'https://open.spotify.com/', - 'Accept': 'application/json', - }, - } - ); - if (atRes.ok) { - const at = await atRes.json(); - if (at.accessToken) { - _spTokenCache = at.accessToken; - _spTokenExpiry = (at.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000; - console.log('[spotify] token ok (anonymous)'); - return _spTokenCache; - } - } - console.log('[spotify] get_access_token status:', atRes.status); + if (!stRes.ok) throw new Error(`server-time ${stRes.status}`); + const { serverTime } = await stRes.json(); + + const totp = _generateTotp(secret, serverTime); + const tRes = await _fetchTimeout( + `https://open.spotify.com/api/token?reason=init&productType=web-player&totp=${totp}&totpVer=${version}&totpServer=${totp}`, + { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'Accept': 'application/json', + 'Referer': 'https://open.spotify.com/', + }, } - } - console.log('[spotify] anonymous token failed, trying Client Credentials'); + ); + if (!tRes.ok) throw new Error(`api/token ${tRes.status}`); + const t = await tRes.json(); + if (!t.accessToken) throw new Error('no accessToken in response'); + + _spTokenCache = t.accessToken; + _spTokenExpiry = (t.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000; + console.log('[spotify] token ok (TOTP)'); + return _spTokenCache; } catch (e) { - console.log('[spotify] anonymous token error:', e.message); + console.log('[spotify] TOTP failed:', e.message); + _spSecretCache = null; // force secret re-extraction next time } - // Approach 2: official Client Credentials (requires SPOTIFY_CLIENT_ID / SECRET in env) + // Approach 2: Client Credentials fallback (requires SPOTIFY_CLIENT_ID/SECRET in env) if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) { throw new Error( - 'Spotify-Token nicht abrufbar. Bitte SPOTIFY_CLIENT_ID und ' + - 'SPOTIFY_CLIENT_SECRET in der Umgebung setzen. ' + - 'Setup: https://developer.spotify.com/dashboard' + 'Spotify-Token nicht abrufbar (TOTP gescheitert). ' + + 'SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET setzen als Fallback.' ); } - const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64'); const r = await _fetchTimeout('https://accounts.spotify.com/api/token', { method: 'POST', - headers: { - 'Authorization': `Basic ${basic}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials', }); - if (!r.ok) { const errBody = await r.text().catch(() => ''); throw new Error(`Spotify-Token-Fehler (${r.status}): ${errBody.slice(0, 200)}`); } - const d = await r.json(); if (!d.access_token) throw new Error('Kein access_token von Spotify erhalten'); - - _spTokenCache = d.access_token; + _spTokenCache = d.access_token; _spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000; console.log('[spotify] token ok (Client Credentials)'); return _spTokenCache;