feat: TOTP-based anonymous Spotify token (no developer credentials needed)

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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-10 23:12:29 +02:00
1 parent 61271bff27
commit fbb00baa11
1 file changed
+107 -61
+107 -61
View File
@@ -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;