Skip to main content
Glama

Spotify Streamable MCP Server

by iceener
oauth.ts12 kB
import { createHash, randomBytes } from 'node:crypto'; import type { HttpBindings } from '@hono/node-server'; import { Hono } from 'hono'; import { config } from '../../config/env.ts'; import { ensureSession } from '../../core/session.ts'; import { generateOpaqueToken as genOpaque, getRecordByRsRefreshToken, storeRsTokenMapping, updateSpotifyTokensByRsRefreshToken, } from '../../core/tokens.ts'; type Txn = { codeVerifierHash: string; // PKCE S256(challenge) state?: string; createdAt: number; scope?: string; sid?: string; spotify?: { access_token: string; refresh_token?: string; expires_at?: number; scopes?: string[]; }; }; const transactions = new Map<string, Txn>(); const codes = new Map<string, string>(); // code -> txnId function b64url(input: Buffer): string { return input .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function b64urlEncodeJson(obj: unknown): string { try { const json = JSON.stringify(obj); return b64url(Buffer.from(json, 'utf8')); } catch { return ''; } } function b64urlDecodeJson<T = unknown>(value: string): T | null { try { const padded = value.replace(/-/g, '+').replace(/_/g, '/'); const buf = Buffer.from(padded, 'base64'); return JSON.parse(buf.toString('utf8')) as T; } catch { return null; } } function sha256B64Url(input: string): string { const hash = createHash('sha256').update(input).digest(); return b64url(hash); } function generateOpaqueToken(bytes = 32): string { return b64url(randomBytes(bytes)); } function isAllowedRedirect(uri: string): boolean { try { const allowRaw = config.OAUTH_REDIRECT_ALLOWLIST || ''; const allowed = new Set( allowRaw .split(',') .map((v) => v.trim()) .filter(Boolean) .concat([config.OAUTH_REDIRECT_URI]), ); const url = new URL(uri); if (config.NODE_ENV === 'development') { const loopback = new Set(['localhost', '127.0.0.1', '::1']); if (loopback.has(url.hostname)) return true; } return ( allowed.has(`${url.protocol}//${url.host}${url.pathname}`) || allowed.has(uri) ); } catch { return false; } } // Periodic cleanup of old transactions setInterval(() => { const now = Date.now(); for (const [tid, txn] of transactions) { if (now - txn.createdAt > 10 * 60_000) { transactions.delete(tid); } } }, 60_000).unref?.(); export function oauthRoutes() { const app = new Hono<{ Bindings: HttpBindings }>(); app.get('/.well-known/oauth-authorization-server', (c) => { const here = new URL(c.req.url); const base = `${here.protocol}//${here.host}`; return c.json({ issuer: base, authorization_endpoint: `${base}/authorize`, token_endpoint: `${base}/token`, revocation_endpoint: `${base}/revoke`, registration_endpoint: `${base}/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], scopes_supported: (config.OAUTH_SCOPES || '').split(' ').filter(Boolean), }); }); app.get('/authorize', (c) => { const url = new URL(c.req.url); const state = url.searchParams.get('state') ?? undefined; const codeChallenge = url.searchParams.get('code_challenge'); const codeChallengeMethod = url.searchParams.get('code_challenge_method'); const redirectUri = url.searchParams.get('redirect_uri'); const requestedScope = url.searchParams.get('scope') ?? undefined; const sid = url.searchParams.get('sid') ?? undefined; if (!redirectUri) return c.text('invalid_request: redirect_uri', 400); if (!codeChallenge || codeChallengeMethod !== 'S256') return c.text('invalid_request: pkce', 400); if (sid) { try { ensureSession(sid); } catch {} } const tid = generateOpaqueToken(16); transactions.set(tid, { codeVerifierHash: codeChallenge, state, createdAt: Date.now(), scope: requestedScope, sid, }); if (config.SPOTIFY_CLIENT_ID && config.SPOTIFY_CLIENT_SECRET) { const accountsBase = config.SPOTIFY_ACCOUNTS_URL; const authUrl = new URL('/authorize', accountsBase); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', config.SPOTIFY_CLIENT_ID); const here = new URL(c.req.url); const cb = new URL( '/spotify/callback', `${here.protocol}//${here.host}`, ).toString(); authUrl.searchParams.set('redirect_uri', cb); const oauthScopes = (config.OAUTH_SCOPES || '') .split(/\s+/) .map((s) => s.trim()) .filter(Boolean) .join(' '); const scopeToUse = oauthScopes || requestedScope || ''; if (scopeToUse) authUrl.searchParams.set('scope', scopeToUse); const compositeState = b64urlEncodeJson({ tid, cs: state, cr: redirectUri, sid }) || tid; authUrl.searchParams.set('state', compositeState); return c.redirect(authUrl.toString(), 302); } // Dev-only shortcut: immediately redirect back with a one-time code const code = generateOpaqueToken(16); codes.set(code, tid); const clientRedirect = redirectUri; const safe = isAllowedRedirect(clientRedirect) ? clientRedirect : config.OAUTH_REDIRECT_URI; const redirect = new URL(safe); redirect.searchParams.set('code', code); if (state) redirect.searchParams.set('state', state); return c.redirect(redirect.toString(), 302); }); app.get('/spotify/callback', async (c) => { try { const here = new URL(c.req.url); const code = here.searchParams.get('code'); const state = here.searchParams.get('state'); if (!code || !state) return c.text('invalid_callback', 400); const decoded = b64urlDecodeJson<{ tid?: string; cs?: string; cr?: string; sid?: string; }>(state) || {}; const txnId = decoded.tid || state; const txn = transactions.get(txnId); if (!txn) return c.text('unknown_txn', 400); const tokenUrl = new URL('/api/token', config.SPOTIFY_ACCOUNTS_URL).toString(); const hereBase = `${here.protocol}//${here.host}`; const cb = new URL('/spotify/callback', hereBase).toString(); const form = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: cb, }); const basic = Buffer.from( `${config.SPOTIFY_CLIENT_ID}:${config.SPOTIFY_CLIENT_SECRET}`, ).toString('base64'); const resp = await fetch(tokenUrl, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: `Basic ${basic}`, }, body: form.toString(), }); if (!resp.ok) { const t = await resp.text().catch(() => ''); return c.text(`spotify_token_error: ${resp.status} ${t}`.trim(), 500); } const data = (await resp.json()) as { access_token?: string; refresh_token?: string; expires_in?: number | string; scope?: string; }; const access_token = String(data.access_token || ''); if (!access_token) return c.text('spotify_no_token', 500); const expires_at = Date.now() + Number(data.expires_in ?? 3600) * 1000; const scopes = String(data.scope || '') .split(/\s+/) .filter(Boolean); (txn as unknown as { spotify?: unknown }).spotify = { access_token, refresh_token: data.refresh_token, expires_at, scopes, }; // Issue AS code back to client const asCode = genOpaque(24); codes.set(asCode, txnId); transactions.set(txnId, txn); const clientRedirect = decoded.cr || config.OAUTH_REDIRECT_URI; const safe = isAllowedRedirect(clientRedirect) ? clientRedirect : config.OAUTH_REDIRECT_URI; const redirect = new URL(safe); redirect.searchParams.set('code', asCode); if (decoded.cs) redirect.searchParams.set('state', decoded.cs); // Session attachment if (decoded.sid) { try { const s = ensureSession(decoded.sid); s.spotify = { access_token, refresh_token: data.refresh_token, expires_at, scopes, }; } catch {} } return c.redirect(redirect.toString(), 302); } catch (_e) { return c.text('spotify_callback_error', 500); } }); app.post('/token', async (c) => { const contentType = c.req.header('content-type') || ''; const form = new URLSearchParams( contentType.includes('application/x-www-form-urlencoded') ? await c.req.text().then((t) => Object.fromEntries(new URLSearchParams(t))) : ((await c.req.json().catch(() => ({}))) as Record<string, string>), ); const grant = form.get('grant_type'); if (grant === 'refresh_token') { const rsRefresh = form.get('refresh_token') || ''; const rec = getRecordByRsRefreshToken(rsRefresh); if (!rec) return c.json({ error: 'invalid_grant' }, 400); const newAccess = genOpaque(24); const updated = updateSpotifyTokensByRsRefreshToken( rsRefresh, rec.spotify, newAccess, ); return c.json({ access_token: newAccess, refresh_token: rsRefresh, token_type: 'bearer', expires_in: 3600, scope: (updated?.spotify.scopes || []).join(' '), }); } if (grant !== 'authorization_code') return c.json({ error: 'unsupported_grant_type' }, 400); const code = form.get('code') || ''; const codeVerifier = form.get('code_verifier') || ''; const txnId = codes.get(code); if (!txnId) return c.json({ error: 'invalid_grant' }, 400); const txn = transactions.get(txnId); if (!txn) return c.json({ error: 'invalid_grant' }, 400); const expected = txn.codeVerifierHash; const actual = sha256B64Url(codeVerifier); if (expected !== actual) return c.json({ error: 'invalid_grant' }, 400); const rsAccess = genOpaque(24); const rsRefresh = genOpaque(24); const spotifyTokens = ( txn as unknown as { spotify?: { access_token: string; refresh_token?: string; expires_at?: number; scopes?: string[]; }; } ).spotify; if (spotifyTokens?.access_token) { storeRsTokenMapping(rsAccess, spotifyTokens, rsRefresh); } // single-use transactions.delete(txnId); codes.delete(code); return c.json({ access_token: rsAccess, refresh_token: rsRefresh, token_type: 'bearer', expires_in: 3600, scope: (spotifyTokens?.scopes || []).join(' ') || txn.scope || (config.OAUTH_SCOPES || '').trim(), }); }); app.post('/revoke', async (c) => c.json({ status: 'ok' })); app.post('/register', async (c) => { const here = new URL(c.req.url); const base = `${here.protocol}//${here.host}`; const requested = (await c.req.json().catch(() => ({}))) as Record<string, unknown>; const now = Math.floor(Date.now() / 1000); const client_id = generateOpaqueToken(12); const redirects = Array.isArray( (requested as { redirect_uris?: unknown }).redirect_uris, ) ? (requested as { redirect_uris: string[] }).redirect_uris || [] : [config.OAUTH_REDIRECT_URI]; return c.json( { client_id, client_id_issued_at: now, client_secret_expires_at: 0, token_endpoint_auth_method: 'none', redirect_uris: redirects, registration_client_uri: `${base}/register/${client_id}`, registration_access_token: generateOpaqueToken(12), }, 201, ); }); return app; }

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/iceener/spotify-streamable-mcp-server'

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