/** * MCP-Based Token Manager for HubSpot OAuth * Handles token refresh through MCP protocol to avoid direct API issues */ import { HubSpotClient } from './hubspot-client.js'; export interface TokenData { portalId: string; accessToken: string; refreshToken: string; expiresAt: number; scope?: string; } export interface TokenRefreshResult { success: boolean; accessToken?: string; refreshToken?: string; expiresAt?: number; error?: string; } export class MCPTokenManager { private hubspotClient: HubSpotClient; private refreshLocks: Map> = new Map(); // HubSpot tokens expire after 30 minutes (1800 seconds) private readonly TOKEN_LIFETIME_MS = 30 * 60 * 1000; // 30 minutes private readonly EARLY_REFRESH_BUFFER_MS = 2 * 60 * 1000; // 2 minutes before expiry private readonly REFRESH_JITTER_MS = 30 * 1000; // 30 seconds jitter constructor(accessToken: string) { this.hubspotClient = new HubSpotClient(accessToken); } /** * Check if token needs refresh based on HubSpot's 30-minute expiry */ needsRefresh(tokenData: TokenData): boolean { const now = Date.now(); const expiresAt = typeof tokenData.expiresAt === 'string' ? new Date(tokenData.expiresAt).getTime() : tokenData.expiresAt; // Add jitter to prevent thundering herd const jitter = Math.random() * this.REFRESH_JITTER_MS; const refreshThreshold = expiresAt - this.EARLY_REFRESH_BUFFER_MS - jitter; return now >= refreshThreshold; } /** * Refresh token using MCP protocol with singleflight locking */ async refreshToken(tokenData: TokenData): Promise { const portalId = tokenData.portalId; // Check if refresh is already in progress (singleflight) if (this.refreshLocks.has(portalId)) { console.log(`[MCP Token Manager] Waiting for existing refresh for portal ${portalId}`); return this.refreshLocks.get(portalId)!; } // Start new refresh with lock const refreshPromise = this.performRefresh(tokenData); this.refreshLocks.set(portalId, refreshPromise); try { const result = await refreshPromise; return result; } finally { // Always clean up the lock this.refreshLocks.delete(portalId); } } /** * Perform the actual token refresh */ private async performRefresh(tokenData: TokenData): Promise { console.log(`[MCP Token Manager] Starting token refresh for portal ${tokenData.portalId}`); try { // Validate refresh token if (!tokenData.refreshToken || tokenData.refreshToken.trim() === '') { throw new Error('Invalid or missing refresh token'); } // HubSpot OAuth v1 token refresh following official API specification // https://developers.hubspot.com/docs/api-reference/auth-oauth-v1/tokens/post-oauth-v1-token const response = await fetch('https://api.hubapi.com/oauth/v1/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'Morphed-MCP-TokenManager/1.0' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: process.env.HUBSPOT_CLIENT_ID!, client_secret: process.env.HUBSPOT_CLIENT_SECRET!, refresh_token: tokenData.refreshToken.trim() }) }); if (!response.ok) { const errorText = await response.text(); console.error(`[MCP Token Manager] Refresh failed: ${response.status} ${errorText}`); if (response.status === 400 && errorText.includes('BAD_REFRESH_TOKEN')) { return { success: false, error: 'REAUTH_REQUIRED: Refresh token is invalid or expired' }; } return { success: false, error: `Token refresh failed: ${response.status} ${errorText}` }; } const refreshData = await response.json(); const { access_token, refresh_token, expires_in } = refreshData; // Calculate new expiration (HubSpot returns expires_in in seconds) const now = Date.now(); const expiresAt = now + (expires_in * 1000); console.log(`[MCP Token Manager] Token refresh successful for portal ${tokenData.portalId}, expires in ${Math.round(expires_in / 60)} minutes`); return { success: true, accessToken: access_token, refreshToken: refresh_token || tokenData.refreshToken, // Some responses don't include new refresh token expiresAt: expiresAt }; } catch (error: any) { console.error(`[MCP Token Manager] Token refresh error:`, error.message); return { success: false, error: error.message }; } } /** * Validate current token by checking with HubSpot */ async validateToken(accessToken: string): Promise<{ valid: boolean; expiresIn?: number; hubId?: string }> { try { const response = await fetch('https://api.hubapi.com/oauth/v1/access-tokens/current', { headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!response.ok) { return { valid: false }; } const tokenInfo = await response.json(); return { valid: true, expiresIn: tokenInfo.expires_in, hubId: tokenInfo.hub_id }; } catch (error) { console.error('[MCP Token Manager] Token validation failed:', error); return { valid: false }; } } /** * Get token status and health information */ getTokenHealth(tokenData: TokenData): { status: 'healthy' | 'expiring_soon' | 'expired'; minutesToExpiry: number; needsRefresh: boolean; } { const now = Date.now(); const expiresAt = typeof tokenData.expiresAt === 'string' ? new Date(tokenData.expiresAt).getTime() : tokenData.expiresAt; const minutesToExpiry = Math.round((expiresAt - now) / 1000 / 60); const needsRefresh = this.needsRefresh(tokenData); let status: 'healthy' | 'expiring_soon' | 'expired'; if (expiresAt <= now) { status = 'expired'; } else if (needsRefresh) { status = 'expiring_soon'; } else { status = 'healthy'; } return { status, minutesToExpiry, needsRefresh }; } }