/** * Token Manager - Handles token lifecycle and refresh */ import { normalizeError, retryWithBackoff, hasFeature } from '../utils/ErrorHandling'; import type { TokenFetchRetryConfig } from '../types'; export interface TokenManagerConfig { apiKey?: string; token?: string; tokenExpiresAt?: number; tokenEndpoint?: string; tokenFetchHeaders?: Record; tokenFetchRetry: TokenFetchRetryConfig; onTokenExpiring?: () => Promise<{ token: string; expiresAt: number }>; autoRefreshToken: boolean; refreshBeforeExpiry: number; baseUrl: string; } export interface TokenManagerEvents { tokenFetchStarted: (source: 'endpoint' | 'api') => void; tokenFetchSucceeded: (data: { token: string; expiresAt: number; source: 'endpoint' | 'api' }) => void; tokenFetchFailed: (error: Error, attempt: number, willRetry: boolean) => void; tokenExpiring: (data: { currentToken: string; expiresAt: number }) => void; tokenRefreshed: (data: { token: string; expiresAt: number }) => void; tokenRefreshFailed: (error: Error) => void; } /** * Manages authentication tokens with automatic refresh */ export class TokenManager { private currentToken: string | null = null; private tokenExpiresAt: number | null = null; private tokenRefreshTimer: ReturnType | null = null; private isRefreshingToken: boolean = false; private readonly config: TokenManagerConfig; private readonly eventHandlers: Partial = {}; constructor(config: TokenManagerConfig) { this.config = config; // Initialize with provided token if (config.token) { this.currentToken = config.token; this.tokenExpiresAt = config.tokenExpiresAt || null; if (config.autoRefreshToken && this.tokenExpiresAt) { this.scheduleTokenRefresh(); } } } /** * Register event handler */ public on( event: K, handler: TokenManagerEvents[K] ): void { this.eventHandlers[event] = handler; } /** * Emit event */ private emit( event: K, ...args: Parameters ): void { const handler = this.eventHandlers[event]; if (handler) { // @ts-expect-error - Dynamic event handling handler(...args); } } /** * Get current valid token */ public async getToken(): Promise { // If we have a token and it's not expired, return it if (this.currentToken && this.isTokenValid()) { return this.currentToken; } // Fetch new token await this.fetchToken(); if (!this.currentToken) { throw new Error('Failed to obtain valid token'); } return this.currentToken; } /** * Check if current token is valid */ public isTokenValid(): boolean { if (!this.currentToken) { return false; } if (!this.tokenExpiresAt) { return true; // Assume valid if no expiry } const now = Date.now() / 1000; // Convert to seconds return this.tokenExpiresAt > now; } /** * Fetch token from appropriate source */ public async fetchToken(): Promise { if (this.isRefreshingToken) { throw new Error('Token refresh already in progress'); } this.isRefreshingToken = true; try { if (this.config.tokenEndpoint) { await this.fetchTokenFromEndpoint(); } else if (this.config.apiKey) { await this.fetchTokenFromApi(); } else { throw new Error('No token source configured'); } } finally { this.isRefreshingToken = false; } } /** * Refresh token using configured callback */ public async refreshToken(): Promise { if (!this.config.onTokenExpiring) { throw new Error('Token refresh callback not configured'); } if (this.isRefreshingToken) { return; // Already refreshing } this.isRefreshingToken = true; try { if (this.currentToken && this.tokenExpiresAt) { this.emit('tokenExpiring', { currentToken: this.currentToken, expiresAt: this.tokenExpiresAt }); } const result = await this.config.onTokenExpiring(); this.currentToken = result.token; this.tokenExpiresAt = result.expiresAt; this.emit('tokenRefreshed', result); // Schedule next refresh if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } } catch (error) { const err = normalizeError(error); this.emit('tokenRefreshFailed', err); throw err; } finally { this.isRefreshingToken = false; } } /** * Schedule automatic token refresh */ private scheduleTokenRefresh(): void { this.cancelTokenRefresh(); if (!this.tokenExpiresAt || !this.config.autoRefreshToken) { return; } const now = Date.now(); const expiresAtMs = this.tokenExpiresAt * 1000; // Convert to milliseconds const refreshTime = expiresAtMs - this.config.refreshBeforeExpiry; const delay = Math.max(0, refreshTime - now); this.tokenRefreshTimer = setTimeout(async () => { try { await this.refreshToken(); } catch (error) { // Error already emitted in refreshToken } }, delay); } /** * Cancel scheduled token refresh */ private cancelTokenRefresh(): void { if (this.tokenRefreshTimer) { clearTimeout(this.tokenRefreshTimer); this.tokenRefreshTimer = null; } } /** * Fetch token from endpoint (browser) */ private async fetchTokenFromEndpoint(): Promise { if (!this.config.tokenEndpoint) { throw new Error('Token endpoint not configured'); } await retryWithBackoff( async (attempt) => { this.emit('tokenFetchStarted', 'endpoint'); const response = await fetch(this.config.tokenEndpoint!, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.tokenFetchHeaders, }, }); if (!response.ok) { throw new Error(`Token endpoint returned ${response.status}: ${response.statusText}`); } const data = await response.json(); if (!data.token) { throw new Error('Token endpoint did not return a token'); } this.currentToken = data.token; this.tokenExpiresAt = data.expires_at || null; this.emit('tokenFetchSucceeded', { token: data.token, expiresAt: data.expires_at, source: 'endpoint' }); // Schedule auto-refresh if enabled if (this.config.autoRefreshToken && this.tokenExpiresAt) { this.scheduleTokenRefresh(); } }, { maxAttempts: this.config.tokenFetchRetry.maxAttempts, delay: this.config.tokenFetchRetry.delay, backoff: this.config.tokenFetchRetry.backoff, jitter: true } ).catch((error) => { this.emit('tokenFetchFailed', normalizeError(error), this.config.tokenFetchRetry.maxAttempts, false); throw error; }); } /** * Fetch token from API using API key (Node.js) */ private async fetchTokenFromApi(): Promise { if (!this.config.apiKey) { throw new Error('API key not configured'); } this.emit('tokenFetchStarted', 'api'); const tokenUrl = `${this.config.baseUrl}/stream/token`; try { if (hasFeature('fetch')) { const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `WioEX-Stream-SDK/1.5.0`, }, }); if (!response.ok) { throw new Error(`Failed to fetch token: HTTP ${response.status}`); } const data = await response.json(); this.currentToken = data.token; this.tokenExpiresAt = data.expires_at; this.emit('tokenFetchSucceeded', { token: data.token, expiresAt: data.expires_at, source: 'api' }); // Schedule auto-refresh if enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } } else { // Fallback to https module for older Node.js versions await this.fetchTokenWithHttps(tokenUrl); } } catch (error: unknown) { const err = normalizeError(error); throw new Error(`Token fetch failed: ${err.message}`); } } /** * Fetch token using https module (fallback) */ private async fetchTokenWithHttps(tokenUrl: string): Promise { const https = await import('https'); const { URL } = await import('url'); return new Promise((resolve, reject) => { const parsedUrl = new URL(tokenUrl); const req = https.request( { hostname: parsedUrl.hostname, port: parsedUrl.port || 443, path: parsedUrl.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${this.config.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': `WioEX-Stream-SDK/1.5.0`, }, }, (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk.toString(); }); res.on('end', () => { try { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } const parsed = JSON.parse(data); this.currentToken = parsed.token; this.tokenExpiresAt = parsed.expires_at; this.emit('tokenFetchSucceeded', { token: parsed.token, expiresAt: parsed.expires_at, source: 'api' }); // Schedule auto-refresh if enabled if (this.config.autoRefreshToken) { this.scheduleTokenRefresh(); } resolve(); } catch (error) { reject(error); } }); } ); req.on('error', reject); req.end(); }); } /** * Cleanup resources */ public destroy(): void { this.cancelTokenRefresh(); this.currentToken = null; this.tokenExpiresAt = null; this.isRefreshingToken = false; } }