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:
1 parent
61271bff27
commit
fbb00baa11
1 file changed
+107
-61
+107
-61
@@ -111,93 +111,139 @@ function _fetchTimeout(url, opts = {}, ms = 10000) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let _spTokenCache = null;
|
let _spTokenCache = null;
|
||||||
let _spTokenExpiry = 0;
|
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() {
|
async function fetchSpotifyToken() {
|
||||||
if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache;
|
if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache;
|
||||||
|
|
||||||
const { randomUUID } = require('crypto');
|
// Approach 1: TOTP-based anonymous token — no developer credentials needed,
|
||||||
|
// uses open.spotify.com/api/token with a computed 6-digit code.
|
||||||
// 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.
|
|
||||||
try {
|
try {
|
||||||
const ctRes = await _fetchTimeout('https://clienttoken.spotify.com/v1/clienttoken', {
|
const { secret, version } = await _fetchSpotifySecret();
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const stRes = await _fetchTimeout('https://open.spotify.com/api/server-time', {
|
||||||
body: JSON.stringify({
|
headers: {
|
||||||
client_data: {
|
'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',
|
||||||
client_version: '1.2.52.442.g276ab0bb',
|
'Accept': 'application/json',
|
||||||
client_id: 'd8a5ed958d274c2e8ee717e6a4b0971d',
|
'Referer': 'https://open.spotify.com/',
|
||||||
js_sdk_data: {
|
},
|
||||||
device_brand: 'unknown', device_model: 'unknown',
|
|
||||||
os: 'windows', os_version: 'NT 10.0',
|
|
||||||
device_id: randomUUID(), device_type: 'computer',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (ctRes.ok) {
|
if (!stRes.ok) throw new Error(`server-time ${stRes.status}`);
|
||||||
const ct = await ctRes.json();
|
const { serverTime } = await stRes.json();
|
||||||
const clientToken = ct.granted_token?.token;
|
|
||||||
if (clientToken) {
|
const totp = _generateTotp(secret, serverTime);
|
||||||
const atRes = await _fetchTimeout(
|
const tRes = await _fetchTimeout(
|
||||||
'https://open.spotify.com/get_access_token?reason=transport&productType=web_player',
|
`https://open.spotify.com/api/token?reason=init&productType=web-player&totp=${totp}&totpVer=${version}&totpServer=${totp}`,
|
||||||
{
|
{
|
||||||
headers: {
|
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',
|
'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()}`,
|
'Accept': 'application/json',
|
||||||
'Client-Token': clientToken,
|
'Referer': 'https://open.spotify.com/',
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
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) {
|
} 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) {
|
if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Spotify-Token nicht abrufbar. Bitte SPOTIFY_CLIENT_ID und ' +
|
'Spotify-Token nicht abrufbar (TOTP gescheitert). ' +
|
||||||
'SPOTIFY_CLIENT_SECRET in der Umgebung setzen. ' +
|
'SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET setzen als Fallback.'
|
||||||
'Setup: https://developer.spotify.com/dashboard'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64');
|
const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64');
|
||||||
const r = await _fetchTimeout('https://accounts.spotify.com/api/token', {
|
const r = await _fetchTimeout('https://accounts.spotify.com/api/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Authorization': `Basic ${basic}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: 'grant_type=client_credentials',
|
body: 'grant_type=client_credentials',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const errBody = await r.text().catch(() => '');
|
const errBody = await r.text().catch(() => '');
|
||||||
throw new Error(`Spotify-Token-Fehler (${r.status}): ${errBody.slice(0, 200)}`);
|
throw new Error(`Spotify-Token-Fehler (${r.status}): ${errBody.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.access_token) throw new Error('Kein access_token von Spotify erhalten');
|
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;
|
_spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000;
|
||||||
console.log('[spotify] token ok (Client Credentials)');
|
console.log('[spotify] token ok (Client Credentials)');
|
||||||
return _spTokenCache;
|
return _spTokenCache;
|
||||||
|
|||||||
Reference in new issue
Block a user