Skip to main content
Glama
igorgarbuz
by igorgarbuz
utils.ts12.3 kB
import { SpotifyApi } from '@spotify/web-api-ts-sdk'; import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath, URL } from 'node:url'; import open from 'open'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG_FILE = path.join(__dirname, '../spotify-config.json'); export interface SpotifyConfig { clientId: string; redirectUri: string; accessToken?: string; refreshToken?: string; accessTokenExpiresAt?: number; // unix ms timestamp } function loadSpotifyConfig(): SpotifyConfig { if (!fs.existsSync(CONFIG_FILE)) { throw new Error( `Spotify configuration file not found at ${CONFIG_FILE}. Please create one with clientId and redirectUri.`, ); } try { const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); if (!config.clientId || !config.redirectUri) { throw new Error( 'Spotify configuration must include clientId and redirectUri.', ); } // If no expiresAt, treat as expired if (!config.accessTokenExpiresAt && config.accessToken) { config.accessTokenExpiresAt = Date.now() - 1000; } return config; } catch (error) { throw new Error( `Failed to parse Spotify configuration: ${ error instanceof Error ? error.message : String(error) }`, ); } } function saveSpotifyConfig(config: SpotifyConfig): void { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); } let cachedSpotifyApi: SpotifyApi | null = null; function createSpotifyApi(): SpotifyApi { if (cachedSpotifyApi) { return cachedSpotifyApi; } const config = loadSpotifyConfig(); if (config.accessToken && config.refreshToken) { // Check expiry (allow 1 min clock skew) if ( config.accessTokenExpiresAt && config.accessTokenExpiresAt - Date.now() < 60 * 1000 ) { throw new Error( 'Access token expired. Please refresh before creating API instance.', ); } const accessToken = { access_token: config.accessToken, token_type: 'Bearer', expires_in: 3600, refresh_token: config.refreshToken, }; cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken); return cachedSpotifyApi; } throw new Error('No access token available. Please authenticate first.'); } function generateRandomString(length: number): string { const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const values = crypto.getRandomValues(new Uint8Array(length)); return values.reduce((acc, x) => acc + possible[x % possible.length], ''); } /** * Generates a PKCE code verifier (43-128 chars, high entropy) */ function generateCodeVerifier(length = 128): string { // RFC 7636: code verifier must be between 43 and 128 chars return generateRandomString(length); } /** * Generates a PKCE code challenge (base64url-encoded SHA256 hash of verifier) */ export async function generateCodeChallenge(verifier: string): Promise<string> { // Convert string to Uint8Array const encoder = new TextEncoder(); const data = encoder.encode(verifier); // Use Web Crypto API for SHA256 const digest = await crypto.subtle.digest('SHA-256', data); // Convert ArrayBuffer to base64url const base64 = Buffer.from(new Uint8Array(digest)).toString('base64'); // base64url encode: replace +/ with -_, remove = return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function exchangeCodeForToken( code: string, config: SpotifyConfig, codeVerifier: string, ): Promise<{ access_token: string; refresh_token: string }> { const tokenUrl = 'https://accounts.spotify.com/api/token'; const params = new URLSearchParams(); params.append('grant_type', 'authorization_code'); params.append('code', code); params.append('redirect_uri', config.redirectUri); params.append('client_id', config.clientId); params.append('code_verifier', codeVerifier); const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', }; const response = await fetch(tokenUrl, { method: 'POST', headers, body: params, }); if (!response.ok) { const errorData = await response.text(); throw new Error(`Failed to exchange code for token: ${errorData}`); } const data = await response.json(); return { access_token: data.access_token, refresh_token: data.refresh_token, }; } export async function authorizeSpotify(): Promise<void> { const config = loadSpotifyConfig(); const redirectUri = new URL(config.redirectUri); if ( redirectUri.hostname !== 'localhost' && redirectUri.hostname !== '127.0.0.1' ) { console.error( 'Error: Redirect URI must use localhost for automatic token exchange', ); console.error( 'Please update your spotify-config.json with a localhost redirect URI', ); console.error('Example: http://127.0.0.1:8888/callback'); process.exit(1); } const port = redirectUri.port || '80'; const callbackPath = redirectUri.pathname || '/callback'; const state = generateRandomString(16); // --- PKCE integration --- const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const scopes = [ 'playlist-modify-private', 'playlist-modify-public', 'playlist-read-private', 'user-follow-read', 'user-library-modify', 'user-library-read', 'user-modify-playback-state', 'user-read-currently-playing', 'user-read-email', 'user-read-playback-state', 'user-read-private', 'user-read-recently-played', 'user-top-read', ]; const authParams = new URLSearchParams({ client_id: config.clientId, response_type: 'code', redirect_uri: config.redirectUri, scope: scopes.join(' '), state: state, show_dialog: 'true', code_challenge: codeChallenge, code_challenge_method: 'S256', }); const authorizationUrl = `https://accounts.spotify.com/authorize?${authParams.toString()}`; const authPromise = new Promise<void>((resolve, reject) => { // Create HTTP server to handle the callback const server = http.createServer(async (req, res) => { if (!req.url) { return res.end('No URL provided'); } const reqUrl = new URL(req.url, `http://localhost:${port}`); if (reqUrl.pathname === callbackPath) { const code = reqUrl.searchParams.get('code'); const returnedState = reqUrl.searchParams.get('state'); const error = reqUrl.searchParams.get('error'); res.writeHead(200, { 'Content-Type': 'text/html' }); if (error) { console.error(`Authorization error: ${error}`); res.end( '<html><body><h1>Authentication Failed</h1><p>Please close this window and try again.</p></body></html>', ); server.close(); reject(new Error(`Authorization failed: ${error}`)); return; } if (returnedState !== state) { console.error('State mismatch error'); res.end( '<html><body><h1>Authentication Failed</h1><p>State verification failed. Please close this window and try again.</p></body></html>', ); server.close(); reject(new Error('State mismatch')); return; } if (!code || typeof code !== 'string') { console.error('No authorization code received'); res.end( '<html><body><h1>Authentication Failed</h1><p>No authorization code received. Please close this window and try again.</p></body></html>', ); server.close(); reject(new Error('No authorization code received')); return; } try { // Exchange code for token (PKCE) const tokenData = await exchangeCodeForToken( code, config, codeVerifier, ); config.accessToken = tokenData.access_token; config.refreshToken = tokenData.refresh_token; saveSpotifyConfig(config); res.end( '<html><body><h1>Authentication Successful!</h1><p>You can now close this window and return to the application.</p></body></html>', ); console.log( 'Authentication successful! Access and refresh tokens have been saved.', ); server.close(); resolve(); } catch (tokenErr) { console.error( 'Failed to exchange authorization code for tokens:', tokenErr, ); res.end( '<html><body><h1>Authentication Failed</h1><p>Failed to exchange authorization code for tokens. Please close this window and try again.</p></body></html>', ); server.close(); reject(tokenErr); } } else { res.writeHead(404); res.end(); } }); server.listen(Number.parseInt(port), '127.0.0.1', () => { console.log( `Listening for Spotify authentication callback on port ${port}`, ); console.log('Opening browser for authorization...'); open(authorizationUrl).catch((error: Error) => { console.log( 'Failed to open browser automatically. Please visit this URL to authorize:', ); console.log(authorizationUrl); }); }); server.on('error', (error: any) => { console.error(`Server error: ${error.message}`); reject(error); }); }); await authPromise; } /** * Refresh the access token using the refresh token (PKCE flow) */ async function refreshAccessToken( config: SpotifyConfig, ): Promise<SpotifyConfig> { if (!config.refreshToken) throw new Error('No refresh token available'); const tokenUrl = 'https://accounts.spotify.com/api/token'; const params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('refresh_token', config.refreshToken); params.append('client_id', config.clientId); const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', }; const response = await fetch(tokenUrl, { method: 'POST', headers, body: params, }); if (!response.ok) { const errorData = await response.text(); throw new Error(`Failed to refresh access token: ${errorData}`); } const data = await response.json(); config.accessToken = data.access_token; // Spotify may or may not return a new refresh token if (data.refresh_token) config.refreshToken = data.refresh_token; // Set new expiry (1 hour from now) config.accessTokenExpiresAt = Date.now() + (data.expires_in ? data.expires_in * 1000 : 3600 * 1000); saveSpotifyConfig(config); return config; } export function formatDuration(ms: number): string { const minutes = Math.floor(ms / 60000); const seconds = ((ms % 60000) / 1000).toFixed(0); return `${minutes}:${seconds.padStart(2, '0')}`; } export async function handleSpotifyRequest<T>( action: (spotifyApi: SpotifyApi) => Promise<T>, ): Promise<T> { let config = loadSpotifyConfig(); let spotifyApi: SpotifyApi; try { // If token is expired, refresh first if ( config.accessTokenExpiresAt && config.accessTokenExpiresAt - Date.now() < 60 * 1000 ) { config = await refreshAccessToken(config); } spotifyApi = createSpotifyApi(); return await action(spotifyApi); } catch (error: any) { // If 401, try refresh once if (error?.status === 401 || /401|unauthorized/i.test(error?.message)) { config = await refreshAccessToken(config); cachedSpotifyApi = null; spotifyApi = createSpotifyApi(); return await action(spotifyApi); } // Skip JSON parsing errors as these are actually successful operations const errorMessage = error instanceof Error ? error.message : String(error); if ( errorMessage.includes('Unexpected token') || errorMessage.includes('Unexpected non-whitespace character') || errorMessage.includes('Exponent part is missing a number in JSON') ) { return undefined as T; } // Rethrow other errors throw error; } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/igorgarbuz/spotify-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server