Skip to main content
Glama
auth.ts9.39 kB
import http from "http"; import fs from "fs/promises"; import { chmodSync } from "fs"; import type { TokenData, TokenStorage, AuthCallbackParams, } from "./types.js"; import { generateState, getTokensPath, ensureConfigDir, openBrowser, } from "./utils.js"; import type { Logger } from "./logger.js"; const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"; const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; const SCOPES = [ "user-read-private", "user-read-playback-state", "user-modify-playback-state", "user-read-recently-played", // For recently played tracks "playlist-read-private", "playlist-modify-public", "playlist-modify-private", "user-library-read", "user-library-modify", ]; export class AuthManager { private clientId: string; private clientSecret: string; private redirectUri: string; private logger: Logger; private tokenData: TokenData | null = null; private state: string | null = null; constructor( clientId: string, clientSecret: string, redirectUri: string, logger: Logger ) { this.clientId = clientId; this.clientSecret = clientSecret; this.redirectUri = redirectUri; this.logger = logger; } /** * Initialize auth manager - load existing tokens if available */ async initialize(): Promise<void> { await ensureConfigDir(); await this.loadTokens(); if (this.tokenData) { await this.logger.info("Loaded existing tokens from storage"); // Check if token needs refresh if (this.isTokenExpired()) { await this.logger.info("Token expired, refreshing..."); await this.refreshAccessToken(); } } else { await this.logger.info("No existing tokens found, authorization required"); } } /** * Check if we have a valid access token */ hasValidToken(): boolean { return this.tokenData !== null && !this.isTokenExpired(); } /** * Get the current access token (refresh if needed) */ async getAccessToken(): Promise<string> { if (!this.tokenData) { throw new Error("No token available. Please authorize first."); } if (this.isTokenExpired()) { await this.logger.info("Token expired, refreshing..."); await this.refreshAccessToken(); } return this.tokenData.access_token; } /** * Start the OAuth authorization flow */ async authorize(): Promise<void> { this.state = generateState(); const authUrl = this.buildAuthUrl(); await this.logger.info("Starting OAuth authorization flow"); await this.logger.info(`Opening browser to: ${authUrl}`); // Start local callback server const code = await this.startCallbackServer(); // Exchange code for tokens await this.exchangeCodeForTokens(code); await this.logger.info("Authorization successful"); } /** * Build the Spotify authorization URL */ private buildAuthUrl(): string { if (!this.state) { throw new Error("State must be generated before building auth URL"); } const params = new URLSearchParams({ client_id: this.clientId, response_type: "code", redirect_uri: this.redirectUri, state: this.state, scope: SCOPES.join(" "), }); return `${SPOTIFY_AUTH_URL}?${params.toString()}`; } /** * Start local HTTP server to receive OAuth callback */ private async startCallbackServer(): Promise<string> { return new Promise((resolve, reject) => { const server = http.createServer(async (req, res) => { const url = new URL(req.url || "", `http://localhost`); if (url.pathname === "/callback") { const params: AuthCallbackParams = { code: url.searchParams.get("code") || undefined, error: url.searchParams.get("error") || undefined, state: url.searchParams.get("state") || undefined, }; if (params.error) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(`<h1>Authorization failed</h1><p>Error: ${params.error}</p>`); server.close(); reject(new Error(`Authorization failed: ${params.error}`)); return; } if (!params.code) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(`<h1>Authorization failed</h1><p>No authorization code received</p>`); server.close(); reject(new Error("No authorization code received")); return; } if (params.state !== this.state) { res.writeHead(400, { "Content-Type": "text/html" }); res.end(`<h1>Authorization failed</h1><p>State mismatch</p>`); server.close(); reject(new Error("State mismatch")); return; } res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <h1>Authorization successful!</h1> <p>You can close this window and return to your terminal.</p> <script>window.close();</script> `); server.close(); resolve(params.code); } }); const port = new URL(this.redirectUri).port || "15732"; server.listen(parseInt(port), () => { this.logger.info(`Callback server listening on port ${port}`); // Open browser after server is ready const authUrl = this.buildAuthUrl(); openBrowser(authUrl).catch((err) => { this.logger.error(`Failed to open browser: ${err.message}`); }); }); server.on("error", (err) => { reject(err); }); }); } /** * Exchange authorization code for access and refresh tokens */ private async exchangeCodeForTokens(code: string): Promise<void> { const params = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, grant_type: "authorization_code", code, redirect_uri: this.redirectUri, }); await this.logger.info("Exchanging authorization code for tokens"); const response = await fetch(SPOTIFY_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} ${errorText}`); } const data = await response.json(); this.tokenData = { access_token: data.access_token, token_type: data.token_type, expires_in: data.expires_in, refresh_token: data.refresh_token, scope: data.scope, expires_at: Date.now() + data.expires_in * 1000, }; await this.saveTokens(); await this.logger.info("Tokens saved successfully"); } /** * Refresh the access token using the refresh token */ private async refreshAccessToken(): Promise<void> { if (!this.tokenData?.refresh_token) { throw new Error("No refresh token available"); } const params = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, grant_type: "refresh_token", refresh_token: this.tokenData.refresh_token, }); await this.logger.info("Refreshing access token"); const response = await fetch(SPOTIFY_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.status} ${errorText}`); } const data = await response.json(); this.tokenData = { ...this.tokenData, access_token: data.access_token, expires_in: data.expires_in, expires_at: Date.now() + data.expires_in * 1000, // Keep existing refresh_token if new one not provided refresh_token: data.refresh_token || this.tokenData.refresh_token, scope: data.scope || this.tokenData.scope, }; await this.saveTokens(); await this.logger.info("Access token refreshed successfully"); } /** * Check if the current token is expired (with 5-minute buffer) */ private isTokenExpired(): boolean { if (!this.tokenData) return true; const bufferMs = 5 * 60 * 1000; // 5 minutes return Date.now() >= this.tokenData.expires_at - bufferMs; } /** * Load tokens from storage */ private async loadTokens(): Promise<void> { try { const tokensPath = getTokensPath(); const data = await fs.readFile(tokensPath, "utf-8"); const storage: TokenStorage = JSON.parse(data); this.tokenData = storage.token_data || null; } catch (error) { // File doesn't exist or is invalid this.tokenData = null; } } /** * Save tokens to storage */ private async saveTokens(): Promise<void> { const tokensPath = getTokensPath(); const storage: TokenStorage = { token_data: this.tokenData || undefined, }; await fs.writeFile(tokensPath, JSON.stringify(storage, null, 2), "utf-8"); // Set file permissions to 0600 (owner read/write only) try { chmodSync(tokensPath, 0o600); } catch (error) { await this.logger.error(`Failed to set token file permissions: ${error}`); } } }

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/nicklaustrup/mcp-spotify'

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