UNPKG

@sudowealth/schwab-api

Version:

TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety

239 lines (238 loc) 11.7 kB
import { MEDIA_TYPES, OAUTH_GRANT_TYPES } from '../constants.js'; import { handleApiError, createSchwabApiError } from '../errors.js'; import { sanitizeAuthCode } from './auth-utils.js'; import { getTokenUrlWithContext } from './urls.js'; /** * Utility function for logging in token-related functions with context */ function tokenLogWithContext(context, level, message, data) { const { config, logger } = context; if (!config.enableLogging) return; const prefix = '[Schwab Auth]'; if (data && level === 'info') { logger[level](`${prefix} ${message}`); } else if (data) { logger[level](`${prefix} ${message}`, data); } else { logger[level](`${prefix} ${message}`); } } /** * Exchange an authorization code for an access token using the provided context */ export async function exchangeCodeForTokenWithContext(context, opts) { const { config } = context; const fetchFn = context.fetchFn; const tokenEndpoint = opts.tokenUrl || getTokenUrlWithContext(context); // Ensure code is properly formatted for Schwab API requirements // Pass context.config.enableLogging for debug flag const authCode = sanitizeAuthCode(opts.code, context.config.enableLogging); tokenLogWithContext(context, 'info', `Code value for exchange: ${authCode.slice(0, 5)}... (length: ${authCode.length})`); const body = new URLSearchParams(); body.append('grant_type', OAUTH_GRANT_TYPES.AUTHORIZATION_CODE); body.append('code', authCode); body.append('redirect_uri', opts.redirectUri); // client_id and client_secret are sent in Authorization header (Basic Auth) const authHeader = 'Basic ' + Buffer.from(`${opts.clientId}:${opts.clientSecret}`).toString('base64'); tokenLogWithContext(context, 'info', `Exchanging code for token at: ${tokenEndpoint}`); // Debug log to help diagnose issues tokenLogWithContext(context, 'info', `Token exchange details: redirect_uri=${opts.redirectUri}, body=${body.toString()}`); try { // Add timeout support const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); // Allow for customization of fetch options through config const requestInit = { method: 'POST', headers: { Authorization: authHeader, 'Content-Type': MEDIA_TYPES.FORM, Accept: 'application/json', }, body: body, signal: controller.signal, }; // Debug log the actual request details tokenLogWithContext(context, 'info', 'Token exchange request headers:', { Authorization: 'Basic ***', // Don't log the actual credentials 'Content-Type': MEDIA_TYPES.FORM, Accept: 'application/json', }); const response = await fetchFn(new Request(tokenEndpoint, requestInit)); // Clear the timeout clearTimeout(timeoutId); // Log response status and headers for debugging const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); tokenLogWithContext(context, 'info', `Token exchange response status: ${response.status}`, { headers: responseHeaders }); let data; try { data = await response.json(); } catch (parseError) { tokenLogWithContext(context, 'error', 'Failed to parse token response:', parseError); // Try to get text content for better debugging try { const textContent = await response.text(); tokenLogWithContext(context, 'error', 'Raw response content:', { text: textContent.slice(0, 200), status: response.status, statusText: response.statusText, }); } catch (textError) { // Unable to get text content tokenLogWithContext(context, 'error', 'Unable to get text content:', textError); } throw createSchwabApiError(response.status || 400, { parseError: 'Invalid JSON response' }, 'Token exchange failed: Invalid JSON response'); } if (!response.ok) { tokenLogWithContext(context, 'error', 'Token exchange failed:', data); // Check for specific Schwab API error codes if (data && data.error) { if (data.error === 'invalid_grant') { // This typically means the authorization code is invalid or expired tokenLogWithContext(context, 'error', 'Invalid or expired authorization code'); } else if (data.error === 'invalid_client') { // This typically means client credentials are incorrect tokenLogWithContext(context, 'error', 'Invalid client credentials (client_id or client_secret)'); } else if (data.error === 'invalid_request') { // This could mean missing parameters or invalid redirect_uri tokenLogWithContext(context, 'error', 'Invalid request - check redirect_uri and required parameters'); } } // Use createSchwabApiError for consistent error creation throw createSchwabApiError(response.status || 400, data, `Token exchange failed: ${data.error || response.statusText} ${data.error_description ? `- ${data.error_description}` : ''}`); } tokenLogWithContext(context, 'info', 'Token exchange successful'); return data; } catch (error) { tokenLogWithContext(context, 'error', 'Error during token exchange:', error); // Use the centralized error handler with context and make sure to throw throw handleApiError(error, 'Token exchange failed'); } } /** * Refresh an access token using a refresh token with context */ export async function refreshTokenWithContext(context, opts) { const { config } = context; const fetchFn = context.fetchFn; const tokenEndpoint = opts.tokenUrl || getTokenUrlWithContext(context); const body = new URLSearchParams(); body.append('grant_type', OAUTH_GRANT_TYPES.REFRESH_TOKEN); body.append('refresh_token', opts.refreshToken); // Explicitly add client_id to payload (some OAuth servers require this in addition to Auth header) body.append('client_id', opts.clientId); // client_id and client_secret are also sent in Authorization header (Basic Auth) const authHeader = 'Basic ' + Buffer.from(`${opts.clientId}:${opts.clientSecret}`).toString('base64'); tokenLogWithContext(context, 'info', `Refreshing token at: ${tokenEndpoint}`); // Debug log payload for troubleshooting tokenLogWithContext(context, 'info', 'Token refresh request details:', { tokenEndpoint, refreshTokenLength: opts.refreshToken.length, }); // Import the token refresh tracer dynamically to avoid circular dependencies const { TokenRefreshTracer } = await import('./token-refresh-tracer.js'); const tracer = TokenRefreshTracer.getInstance(); const refreshId = tracer.startRefreshTrace(); // Capture the full request details for diagnostics const requestHeaders = { Authorization: authHeader, 'Content-Type': MEDIA_TYPES.FORM, Accept: 'application/json', }; // Log very detailed request information tokenLogWithContext(context, 'info', `Token refresh request [ID: ${refreshId}] to: ${tokenEndpoint}`, { method: 'POST', contentType: MEDIA_TYPES.FORM, refreshTokenLength: opts.refreshToken.length, refreshTokenFirstChars: opts.refreshToken.substring(0, 4), clientIdFirstChars: opts.clientId.substring(0, 4), currentTimestamp: new Date().toISOString(), requestId: refreshId, }); // Record the HTTP request in the tracer tracer.recordRefreshRequest(refreshId, tokenEndpoint, 'POST', requestHeaders, body.toString()); try { // Add timeout support const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); const startTime = Date.now(); const response = await fetchFn(new Request(tokenEndpoint, { method: 'POST', headers: requestHeaders, body: body, signal: controller.signal, })); // Clear the timeout clearTimeout(timeoutId); const requestDuration = Date.now() - startTime; // Log response details const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); tokenLogWithContext(context, 'info', `Token refresh response [ID: ${refreshId}] status: ${response.status} (${requestDuration}ms)`, { status: response.status, statusText: response.statusText, headers: responseHeaders, durationMs: requestDuration, }); let data; try { data = await response.json(); } catch (parseError) { tokenLogWithContext(context, 'error', 'Failed to parse token refresh response:', parseError); // Try to get text content for better debugging try { const textContent = await response.text(); tokenLogWithContext(context, 'error', 'Raw response content:', { text: textContent.slice(0, 200), status: response.status, statusText: response.statusText, }); } catch (textError) { // Unable to get text content tokenLogWithContext(context, 'error', 'Unable to get text content:', textError); } throw createSchwabApiError(response.status || 400, { parseError: 'Invalid JSON response' }, 'Token refresh failed: Invalid JSON response'); } if (!response.ok) { tokenLogWithContext(context, 'error', 'Token refresh failed:', data); // Handle special cases for refresh token errors if (data.error === 'refresh_token_authentication_error') { throw createSchwabApiError(401, data, 'Refresh token authentication failed - the token may have expired or been revoked. A new authentication flow is required.'); } else if (data.error === 'unsupported_token_type') { throw createSchwabApiError(401, data, `Token refresh failed: The refresh token format is not supported - ${data.error_description || ''}`); } else if (data.error === 'invalid_grant') { throw createSchwabApiError(401, data, 'Token refresh failed: Invalid or expired refresh token. A new authentication flow is required.'); } else { // Use createSchwabApiError for consistent error creation throw createSchwabApiError(response.status || 400, data, `Token refresh failed: ${data.error || response.statusText} - ${data.error_description || ''}`); } } tokenLogWithContext(context, 'info', 'Token refresh successful'); return data; } catch (error) { tokenLogWithContext(context, 'error', 'Error during token refresh:', error); // Use the centralized error handler with context and make sure to throw throw handleApiError(error, 'Token refresh failed'); } }