UNPKG

@sudowealth/schwab-api

Version:

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

1,040 lines (1,039 loc) 64.4 kB
import pkceChallenge from 'pkce-challenge'; import { API_URLS, API_VERSIONS } from '../constants.js'; import { SchwabAuthError, AuthErrorCode } from '../errors.js'; import { createLogger } from '../utils/secure-logger.js'; import { getAuthDiagnostics, } from './auth-diagnostics.js'; import { sanitizeAuthCode, safeBase64Encode, safeBase64Decode, DEFAULT_REFRESH_THRESHOLD_MS, } from './auth-utils.js'; import { TokenRefreshTracer } from './token-refresh-tracer.js'; import { validateTokenData, validateTokenDetailed, ensureCompleteTokenData, } from './token-validation.js'; // Define additional error codes for enhanced token manager export var TokenErrorCode; (function (TokenErrorCode) { TokenErrorCode["AUTHORIZATION_ERROR"] = "AUTHORIZATION_ERROR"; TokenErrorCode["REFRESH_FAILED"] = "REFRESH_FAILED"; })(TokenErrorCode || (TokenErrorCode = {})); /** * Enhanced events for token persistence lifecycle */ export var TokenPersistenceEvent; (function (TokenPersistenceEvent) { TokenPersistenceEvent["TOKEN_SAVED"] = "token_saved"; TokenPersistenceEvent["TOKEN_SAVE_FAILED"] = "token_save_failed"; TokenPersistenceEvent["TOKEN_LOADED"] = "token_loaded"; TokenPersistenceEvent["TOKEN_LOAD_FAILED"] = "token_load_failed"; TokenPersistenceEvent["TOKEN_VALIDATED"] = "token_validated"; TokenPersistenceEvent["TOKEN_VALIDATION_FAILED"] = "token_validation_failed"; })(TokenPersistenceEvent || (TokenPersistenceEvent = {})); /** * Enhanced token manager that provides improved token lifecycle management, * with robust persistence, validation, retry logic, and reconnection handling. */ export class EnhancedTokenManager { config; tokenSet; // Persistence-related properties (integrated from TokenPersistenceManager) saveFn; loadFn; persistenceDebugEnabled; validateOnLoad; persistenceEventHandler; lastSavedTokens; lastLoadedTokens; tracer; refreshCallbacks = []; reconnectionHandlers = []; isReconnecting = false; refreshLock = null; // Logger instance for secure logging logger = createLogger('EnhancedTokenManager'); constructor(options) { // Set default configuration values const baseIssuerUrl = options.issuerBaseUrl ?? `${API_URLS.PRODUCTION}/${API_VERSIONS.v1}`; this.config = { clientId: options.clientId, clientSecret: options.clientSecret, redirectUri: options.redirectUri, scope: options.scope || ['api', 'offline_access'], fetch: options.fetch || globalThis.fetch.bind(globalThis), load: options.load, save: options.save, maxRetryAttempts: options.maxRetryAttempts ?? 3, initialRetryDelayMs: options.initialRetryDelayMs ?? 1000, maxRetryDelayMs: options.maxRetryDelayMs ?? 30000, useExponentialBackoff: options.useExponentialBackoff !== false, refreshThresholdMs: options.refreshThresholdMs ?? DEFAULT_REFRESH_THRESHOLD_MS, debug: options.debug ?? false, validateTokens: options.validateTokens !== false, autoReconnect: options.autoReconnect !== false, onTokenEvent: options.onTokenEvent, traceOperations: options.traceOperations ?? false, issuerBaseUrl: baseIssuerUrl, }; // Create dummy implementations for when the actual functions are missing const dummySave = this.config.save || (async () => { // No save function provided return; }); const dummyLoad = this.config.load || (async () => { // No load function provided return null; }); // Create a wrapper for the token event handler this.persistenceEventHandler = this.config.onTokenEvent || ((_event, _data) => { // No event handler provided return; }); // Initialize persistence-related properties (integrated from TokenPersistenceManager) this.saveFn = dummySave; this.loadFn = dummyLoad; this.persistenceDebugEnabled = this.config.debug; this.validateOnLoad = this.config.validateTokens; // Get the token refresh tracer instance this.tracer = TokenRefreshTracer.getInstance({ includeRawResponses: false, }); // Log configuration for debugging if (this.config.debug) { // Debug log removed } } /** * Get the authorization URL for the OAuth flow with PKCE support * This is an asynchronous method to properly generate and include the code challenge */ codeVerifierForCurrentFlow = null; // Use this to hold the verifier for the current auth URL generation async getAuthorizationUrl(opts) { const scope = opts?.scope || this.config.scope; const baseIssuerUrl = this.config.issuerBaseUrl; // Generate PKCE code verifier and challenge using pkce-challenge package const pkce = await pkceChallenge(); this.codeVerifierForCurrentFlow = pkce.code_verifier; // Store the verifier for later use const codeChallenge = pkce.code_challenge; // Get the pre-computed challenge if (this.config.debug && this.codeVerifierForCurrentFlow) { this.logger.debug(`PKCE for getAuthUrl: verifier (len ${this.codeVerifierForCurrentFlow.length}), challenge (len ${codeChallenge.length}, starts ${codeChallenge.substring(0, 10)}...)`); } const authParams = { client_id: this.config.clientId, scope: scope.join(' '), response_type: 'code', redirect_uri: this.config.redirectUri, code_challenge: codeChallenge, code_challenge_method: 'S256', }; // Construct the state to be sent to Schwab // It will contain the calling app's original state AND our pkce_code_verifier let appSpecificStateData = {}; if (opts?.state) { try { // First, make sure the state is a valid base64 string let decodedStateString; try { decodedStateString = safeBase64Decode(opts.state); } catch (decodeError) { if (this.config.debug) { this.logger.warn(`[EnhancedTokenManager] opts.state in getAuthorizationUrl failed base64 decoding: ${decodeError.message}. Treating as raw string.`); } // If not base64, just use the raw string decodedStateString = opts.state; } // Try to parse as JSON try { appSpecificStateData = JSON.parse(decodedStateString); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager] Successfully parsed opts.state as JSON: ${JSON.stringify(appSpecificStateData).substring(0, 100)}...`); } } catch (jsonError) { if (this.config.debug) { this.logger.warn(`[EnhancedTokenManager] Decoded state string is not valid JSON. Treating as opaque string. Error: ${jsonError.message}`); } // If not JSON, wrap it as a string value appSpecificStateData = { original_app_state: decodedStateString }; } } catch (e) { if (this.config.debug) { this.logger.warn(`[EnhancedTokenManager] Failed to process opts.state in getAuthorizationUrl. Treating as opaque string. Original state: ${opts.state}`); } this.logger.error('Error processing opts.state in getAuthorizationUrl:', e); // Fall back to treating it as a raw string appSpecificStateData = { original_app_state: opts.state }; } } const combinedStateObject = { ...appSpecificStateData, // The calling app's state (e.g., oauthReqInfo) pkce_code_verifier: this.codeVerifierForCurrentFlow, // Our PKCE verifier }; // Create the combined state with more robust base64 encoding let finalStateParamForSchwab; try { // Use the safe base64 encoding helper finalStateParamForSchwab = safeBase64Encode(JSON.stringify(combinedStateObject)); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager] Generated state param (len ${finalStateParamForSchwab.length}): ${finalStateParamForSchwab.substring(0, 20)}...`); } } catch (encodeError) { this.logger.error(`[EnhancedTokenManager] Critical error encoding state: ${encodeError.message}`); // Fall back to a simpler approach - encode just the code verifier without user state // This ensures PKCE still works even if we can't include the app state try { const fallbackState = { pkce_code_verifier: this.codeVerifierForCurrentFlow, }; finalStateParamForSchwab = safeBase64Encode(JSON.stringify(fallbackState)); this.logger.warn('[EnhancedTokenManager] Using fallback state encoding (app state discarded due to encoding error)'); } catch (fallbackError) { // If all encoding fails, we're in a bad state but can't do much this.logger.error(`[EnhancedTokenManager] Fatal encoding error for state: ${fallbackError.message}`); // Don't include state at all - the exchange will fail but that's better than crashing here finalStateParamForSchwab = ''; } } // Only add state if we successfully created it if (finalStateParamForSchwab) { authParams.state = finalStateParamForSchwab; } const authUrl = `${baseIssuerUrl}/oauth/authorize?${new URLSearchParams(authParams).toString()}`; // Only return generatedState if we successfully created it return { authUrl, ...(finalStateParamForSchwab ? { generatedState: finalStateParamForSchwab } : {}), }; } // Using the unified sanitizeAuthCode function from auth-utils.ts /** * Exchange an authorization code for tokens * This method is used after the user completes the authorization flow * @param code The authorization code received from the OAuth server * @param stateParam Optional state parameter received in the callback, may contain code_verifier */ async exchangeCode(code, stateParam) { if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] Received raw authorization code (length: ${code.length}): '${code.substring(0, 15)}...'`); this.logger.debug(`[EnhancedTokenManager.exchangeCode] Received raw stateParam (length: ${stateParam?.length || 0}): '${stateParam ? stateParam.substring(0, 30) + '...' : 'undefined'}'`); } const sanitizedCode = sanitizeAuthCode(code, this.config.debug); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] Code was sanitized to: '${sanitizedCode.substring(0, 15)}...'`); this.logger.debug(`[EnhancedTokenManager.exchangeCode] Code length before: ${code.length}, after: ${sanitizedCode.length}`); this.logger.debug(`[EnhancedTokenManager.exchangeCode] Sanitized code length validation: ${sanitizedCode.length % 4 === 0 ? 'Valid length (multiple of 4)' : 'INVALID length (not a multiple of 4)'}`); } let retrievedCodeVerifier = null; if (!stateParam) { this.logger.error('[EnhancedTokenManager.exchangeCode] CRITICAL: stateParam is missing. PKCE code_verifier cannot be retrieved from state. This will likely lead to token exchange failure.'); // Attempt to use instance-stored verifier as a last resort, though this is less reliable across redirects // if a new ETM instance is created for the callback. if (this.codeVerifierForCurrentFlow) { // Assuming you renamed this.codeVerifier to this.codeVerifierForCurrentFlow this.logger.warn('[EnhancedTokenManager.exchangeCode] Attempting to use instance-stored codeVerifierForCurrentFlow as fallback due to missing stateParam.'); retrievedCodeVerifier = this.codeVerifierForCurrentFlow; } } else { // First, pre-process the stateParam to handle potential URL encoding let processedStateParam = stateParam; if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] Processing stateParam (length: ${stateParam.length}, preview: '${stateParam.substring(0, 30)}...')`); } // Check if the stateParam might be URL-encoded if (stateParam.includes('%')) { try { const decodedStateParam = decodeURIComponent(stateParam); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] State appears to be URL-encoded, decoded to: '${decodedStateParam.substring(0, 30)}...'`); } processedStateParam = decodedStateParam; } catch (e) { this.logger.warn(`[EnhancedTokenManager.exchangeCode] Failed to URL-decode stateParam. Will use as-is. Error: ${e.message}`); // Continue with original state param } } try { // Use our safe base64 decode function instead of atob const decodedStateString = safeBase64Decode(processedStateParam); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] Decoded state string from base64: '${decodedStateString.substring(0, 100)}...'`); } const decodedStateObject = JSON.parse(decodedStateString); if (decodedStateObject && decodedStateObject.pkce_code_verifier) { retrievedCodeVerifier = decodedStateObject.pkce_code_verifier; if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.exchangeCode] Successfully retrieved pkce_code_verifier from stateParam (length: ${retrievedCodeVerifier?.length}, starts with: ${retrievedCodeVerifier?.substring(0, 10)}...)`); } } else { this.logger.warn('[EnhancedTokenManager.exchangeCode] pkce_code_verifier NOT found in decoded stateParam object. Decoded state:', decodedStateObject); } } catch (e) { this.logger.error(`[EnhancedTokenManager.exchangeCode] Failed to decode or parse stateParam: ${e.message}. Raw stateParam was: '${stateParam.substring(0, 50)}...'`); // Even if state decoding fails, check if this.codeVerifierForCurrentFlow (instance property) was set as a desperate fallback if (this.codeVerifierForCurrentFlow) { this.logger.warn('[EnhancedTokenManager.exchangeCode] State decoding failed. Attempting to use instance-stored codeVerifierForCurrentFlow as fallback.'); retrievedCodeVerifier = this.codeVerifierForCurrentFlow; } } } if (!retrievedCodeVerifier) { const errorMessage = '[EnhancedTokenManager.exchangeCode] CRITICAL: No code_verifier available for PKCE token exchange. Cannot proceed with token exchange.'; this.logger.error(errorMessage); if (this.config.debug && this.codeVerifierForCurrentFlow) { // This log helps understand if getAuthorizationUrl did set it on the instance this.logger.debug(`[EnhancedTokenManager.exchangeCode] Debug info: this.codeVerifierForCurrentFlow was (len ${this.codeVerifierForCurrentFlow.length}): ${this.codeVerifierForCurrentFlow.substring(0, 10)}...`); } throw new SchwabAuthError(AuthErrorCode.PKCE_VERIFIER_MISSING, 'PKCE code_verifier is missing or could not be retrieved. Token exchange cannot be completed.'); } const params = { grant_type: 'authorization_code', code: sanitizedCode, redirect_uri: this.config.redirectUri, client_id: this.config.clientId, client_secret: this.config.clientSecret, // Schwab requires client_secret for server-side token exchange even with PKCE code_verifier: retrievedCodeVerifier, }; if (this.config.debug) { const paramsForLog = { ...params }; if (paramsForLog.client_secret) paramsForLog.client_secret = '[REDACTED]'; if (paramsForLog.code) paramsForLog.code = `${paramsForLog.code.substring(0, 10)}... (len: ${paramsForLog.code.length})`; if (paramsForLog.code_verifier) paramsForLog.code_verifier = `${paramsForLog.code_verifier.substring(0, 10)}... (len: ${paramsForLog.code_verifier.length})`; this.logger.debug('[EnhancedTokenManager.exchangeCode] Parameters for performDirectTokenExchange:', paramsForLog); } try { const tokenResponseData = await this.performDirectTokenExchange(params); const tokenData = { accessToken: tokenResponseData.access_token, refreshToken: tokenResponseData.refresh_token || '', expiresAt: Date.now() + (tokenResponseData.expires_in || 0) * 1000, }; this.tokenSet = tokenResponseData; // Store the raw response await this.persistTokens(tokenData, { operation: 'code_exchange', codeLength: code.length, // Original code length usedPkce: true, timestamp: Date.now(), }); // Clear the instance verifier after successful use if it was the source // If it came from state, this.codeVerifierForCurrentFlow might be for a *previous* auth attempt if not careful with instance reuse. // It's generally safer to rely on the state-passed verifier for the specific exchange. this.codeVerifierForCurrentFlow = null; if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.exchangeCode] Token exchange successful. Access token preview:', `${tokenData.accessToken.substring(0, 8)}...`, `Expires in: ${tokenResponseData.expires_in}s`); } return tokenData; } catch (error) { this.logger.error('[EnhancedTokenManager.exchangeCode] Error during performDirectTokenExchange:', error.message || error); // The error from performDirectTokenExchange might already be well-formatted. // If it's a generic Error, re-wrap it. if (!(error instanceof SchwabAuthError)) { throw this.formatTokenError( // Ensure formatTokenError exists and works error, 'Failed to exchange authorization code for tokens during direct exchange.', AuthErrorCode.UNAUTHORIZED); } throw error; // Re-throw if already a SchwabAuthError } } /** * Implement the ITokenLifecycleManager interface */ /** * Explicitly initialize the token manager, ensuring tokens are loaded, validated, and refreshed if needed * @returns Promise<boolean> True if a valid access token is available after initialization, false otherwise */ async initialize() { if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.initialize] Explicit initialization called.'); } try { const tokenData = await this.getTokenData(); // Triggers load and initial validation if (!tokenData || !tokenData.accessToken) { if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.initialize] No token data available after getTokenData.'); } return false; // No token available } if (this.shouldRefreshToken(tokenData.expiresAt, this.config.refreshThresholdMs)) { if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.initialize] Token needs refresh.'); } if (!tokenData.refreshToken) { if (this.config.debug) { this.logger.warn('[EnhancedTokenManager.initialize] Token needs refresh, but no refresh token available. Cannot refresh during init.'); } return false; // Cannot refresh, indicate initialization didn't achieve a fully ready state } try { if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.initialize] Attempting refreshIfNeeded due to expiring token.'); } // This internal refreshIfNeeded CAN throw if the refresh token is actually bad. // We want to catch that here so initialize() itself doesn't throw. await this.refreshIfNeeded({ refreshToken: tokenData.refreshToken, force: true, }); // After a refresh attempt, re-fetch tokenData to get the latest state. const refreshedTokenData = await this.getTokenData(); const isNowValid = !!(refreshedTokenData && refreshedTokenData.accessToken && !this.shouldRefreshToken(refreshedTokenData.expiresAt, 0)); if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.initialize] Post-refresh check, token is now valid: ${isNowValid}`); } return isNowValid; } catch (refreshError) { // Log the error, but don't let it propagate from initialize() const message = refreshError instanceof Error ? refreshError.message : String(refreshError); this.logger.warn(`[EnhancedTokenManager.initialize] Refresh attempt during initialization failed: ${message}`); if (this.config.debug && refreshError instanceof Error) { this.logger.error(refreshError); // Log full error in debug mode } // Even if refresh failed, check if the *original* tokenData is still usable (e.g., refresh failed due to network, but token not yet hard expired) // However, it's safer to return false, as we couldn't get to a definitively good state. return false; } } if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.initialize] Initialization successful, token is valid and does not need immediate refresh.'); } return true; } catch (error) { // Catch errors from the initial getTokenData() or other unexpected issues const message = error instanceof Error ? error.message : String(error); this.logger.error(`[EnhancedTokenManager.initialize] Error during initialization: ${message}`); if (this.config.debug && error instanceof Error) { this.logger.error(error); // Log full error } return false; } } /** * Get the current token data * Handles token loading and validation */ async getTokenData() { // Wait for any ongoing reconnection process to complete if (this.isReconnecting) { if (this.config.debug) { // Debug log removed } await this.waitForReconnection(); } // If we have tokens already, use them if (this.tokenSet) { return this.mapToTokenData(this.tokenSet); } // If we don't have tokens and there's a load function, try to load from storage if (this.loadFn) { if (this.config.debug) { // Debug log removed } try { const loadedTokens = await this.loadTokensFromStorage(); // If tokens were loaded, validate and potentially refresh them if (loadedTokens) { if (this.config.debug) { // Debug log removed } // Store the tokens in memory this.tokenSet = { access_token: loadedTokens.accessToken, refresh_token: loadedTokens.refreshToken, expires_in: 0, // Not used directly token_type: 'bearer', }; // Check if the tokens need to be refreshed const shouldRefresh = this.shouldRefreshToken(loadedTokens.expiresAt); if (shouldRefresh && loadedTokens.refreshToken) { if (this.config.debug) { // Debug log removed } try { // Refresh the tokens since they're about to expire await this.refreshIfNeeded({ refreshToken: loadedTokens.refreshToken, force: true, }); // After refresh, get the updated tokens return this.mapToTokenData(this.tokenSet); } catch (e) { // Log the error but don't throw - return the current tokens if still valid if (this.config.debug) { // Warning log removed } // If the current tokens are still valid, use them if (loadedTokens.expiresAt && loadedTokens.expiresAt > Date.now()) { return loadedTokens; } this.logger.error('No valid tokens available', e); // Otherwise, return null to indicate no valid tokens return null; } } // If refresh not needed, return the loaded tokens return loadedTokens; } } catch (error) { // Error loading tokens, return null this.logger.error('Error loading tokens', error); return null; } } // No tokens available return null; } /** * Get the access token only */ async getAccessToken() { const tokenData = await this.getTokenData(); return tokenData ? tokenData.accessToken : null; } /** * Check if this manager supports token refresh */ supportsRefresh() { return true; } /** * Refresh the tokens if needed * This is a core method that handles token refresh logic * with concurrency control */ async refreshIfNeeded(options) { // If refresh is already in progress, wait for it to complete if (this.refreshLock) { if (this.config.debug) { // Debug log removed } return this.refreshLock; } // Get the current token data const tokenData = await this.getTokenData(); // Check if a refresh is needed const force = options?.force || false; const refreshThresholdMs = this.config.refreshThresholdMs; // Force refresh or check if token is expiring soon const shouldRefresh = force || (tokenData && this.shouldRefreshToken(tokenData.expiresAt, refreshThresholdMs)); if (shouldRefresh) { if (this.config.debug) { // Debug log removed } // Get the refresh token to use const refreshToken = options?.refreshToken || tokenData?.refreshToken || ''; if (!refreshToken) { throw new SchwabAuthError(AuthErrorCode.REFRESH_NEEDED, 'No refresh token available for token refresh'); } // Create the refresh lock and start the refresh process this.refreshLock = this.doRefreshWithRetry(refreshToken, force); try { // Wait for the refresh to complete const result = await this.refreshLock; return result; } finally { // Clear the lock when done this.refreshLock = null; } } else if (tokenData) { // No refresh needed, return the current token data return tokenData; } // No valid tokens and no refresh possible throw new SchwabAuthError(AuthErrorCode.REFRESH_NEEDED, 'No valid tokens available and cannot refresh'); } /** * Register a callback to be notified when tokens are refreshed */ onRefresh(callback) { this.refreshCallbacks.push(callback); } /** * Refresh tokens using a specific refresh token * This implements the FullAuthClient interface */ async refresh(refreshToken, options) { const tokenData = await this.refreshIfNeeded({ refreshToken, force: options?.force, }); return { accessToken: tokenData.accessToken, refreshToken: tokenData.refreshToken || '', expiresAt: tokenData.expiresAt || 0, }; } /** * Register a callback to handle reconnection events * This is useful for handling token expiration scenarios */ addReconnectionHandler(handler) { this.reconnectionHandlers.push(handler); } /** * Trigger the reconnection process * This calls all registered reconnection handlers */ async triggerReconnection() { // If reconnection is already in progress, return if (this.isReconnecting) { if (this.config.debug) { // Debug log removed } return; } try { if (this.config.debug) { // Debug log removed } // Mark reconnection as in progress this.isReconnecting = true; // Call all reconnection handlers for (const handler of this.reconnectionHandlers) { try { await handler(); } catch (e) { this.logger.error('Error calling reconnection handler', e); if (this.config.debug) { // Warning log removed } } } if (this.config.debug) { // Debug log removed } } catch (error) { this.logger.error('Error triggering reconnection', error); if (this.config.debug) { // Error log removed } } finally { // Reset reconnection flag this.isReconnecting = false; } } /** * Wait for any ongoing reconnection process to complete */ async waitForReconnection() { // Simple polling approach to wait for reconnection to complete while (this.isReconnecting) { await new Promise((resolve) => setTimeout(resolve, 100)); } } /** * Check if a token needs to be refreshed */ shouldRefreshToken(expiresAt, thresholdMs) { if (!expiresAt) { return true; } // Use provided threshold or default const refreshThreshold = thresholdMs ?? this.config.refreshThresholdMs; const now = Date.now(); return expiresAt <= now + refreshThreshold; } /** * Map OIDC token response to our TokenData format */ mapToTokenData(tokenSet) { if (!tokenSet || !tokenSet.access_token) { return null; } // Calculate expiration time in milliseconds const expiresAtMs = Date.now() + (tokenSet.expires_in || 0) * 1000; return { accessToken: tokenSet.access_token, refreshToken: tokenSet.refresh_token || '', expiresAt: expiresAtMs, }; } /** * Load tokens from persistent storage */ async loadTokensFromStorage() { try { if (!this.loadFn) { return null; } const tokens = await this.loadFn(); if (!tokens) { // No tokens available this.logPersistenceEvent('No tokens found in storage'); return null; } // Track loaded tokens for event handling this.lastLoadedTokens = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, }; // Notify about token load success this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_LOADED, this.lastLoadedTokens); // Optional validation if enabled if (this.validateOnLoad) { const isValid = this.validateTokens(tokens); if (!isValid) { // Tokens failed validation this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_VALIDATION_FAILED, this.lastLoadedTokens); return null; } // Tokens are valid this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_VALIDATED, this.lastLoadedTokens); } return this.lastLoadedTokens; } catch (error) { // Handle load error this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_LOAD_FAILED, { accessToken: '', refreshToken: '', expiresAt: 0 }, { error }); // Wrap and rethrow as SchwabAuthError throw new SchwabAuthError(AuthErrorCode.TOKEN_PERSISTENCE_LOAD_FAILED, error instanceof Error ? error.message : 'Failed to load tokens from storage', undefined, // No HTTP status for this internal error { originalError: error }); } } /** * Persist tokens to storage * @param tokens The token set to persist * @param metadata Optional metadata about the persistence operation */ async persistTokens(tokens, metadata) { try { if (!this.saveFn) { return; } // Validate tokens before saving const isValid = this.validateTokens(tokens); if (!isValid) { throw new SchwabAuthError(AuthErrorCode.TOKEN_VALIDATION_ERROR, 'Invalid tokens, refusing to save to persistence.'); } // Track tokens being saved this.lastSavedTokens = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, }; // Save tokens await this.saveFn(tokens); // Notify of successful save with metadata this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_SAVED, this.lastSavedTokens, metadata); } catch (error) { // Handle save error this.dispatchTokenEvent(TokenPersistenceEvent.TOKEN_SAVE_FAILED, this.lastSavedTokens || { accessToken: '', refreshToken: '', expiresAt: 0, }, { error }); // Rethrow to signal failure throw error; } } /** * Validate tokens to ensure they meet basic requirements */ validateTokens(tokens) { return validateTokenData(tokens); } /** * Clear all tokens from memory and storage */ async clearTokens() { // Clear in-memory tokens this.tokenSet = undefined; // Clear persistent storage if available if (this.saveFn) { try { await this.saveFn({ accessToken: '', refreshToken: '', expiresAt: 0, }); } catch (error) { this.logger.error('Error clearing tokens', error); } } if (this.config.debug) { // Debug log removed } } /** * Manually save tokens * @param tokens The token set to save * @param metadata Optional metadata about the save operation */ async saveTokens(tokens, metadata) { // Create a valid TokenData from the partial input const tokenData = ensureCompleteTokenData(tokens); // Store in memory this.tokenSet = { access_token: tokenData.accessToken, refresh_token: tokenData.refreshToken, expires_in: Math.floor((tokenData.expiresAt - Date.now()) / 1000), token_type: 'bearer', }; // Persist to storage if available if (this.saveFn) { await this.persistTokens(tokenData, metadata); } } /** * Helper for logging persistence-related events */ logPersistenceEvent(_message, _data) { if (this.persistenceDebugEnabled) { // Debug log removed } } /** * Dispatch token lifecycle events */ dispatchTokenEvent(event, data, metadata) { try { this.persistenceEventHandler?.(event, data, metadata); } catch (error) { this.logger.error('Error dispatching token event', error); } } /** * Perform a direct token refresh using the OIDC client */ async performDirectTokenRefresh(refreshToken) { try { // Check if the refresh token needs URL encoding // Schwab refresh tokens often include special characters that require encoding if (refreshToken.includes('+') || refreshToken.includes('/') || refreshToken.includes('=')) { if (this.config.debug) { // Debug log removed } // Some tokens may be base64 encoded try { // Attempt to decode to check format const decoded = safeBase64Decode(refreshToken); if (decoded && decoded.length > 0) { // Token appears to be valid base64 encoded // No need to re-encode } } catch (e) { this.logger.error('Error decoding refresh token', e); // Error during safe base64 decode implies not valid base64, may need encoding if (this.config.debug) { // Error log removed } } } // Prepare the refresh token request const params = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.config.clientId, client_secret: this.config.clientSecret, }; // Perform the token exchange return await this.performDirectTokenExchange(params); } catch (error) { // Rethrow with enhanced context throw this.formatTokenError(error, 'Failed to refresh token', AuthErrorCode.TOKEN_EXPIRED); } } /** * Perform a direct token exchange using fetch * This avoids direct dependency on specific OIDC implementations */ async performDirectTokenExchange(params) { // Prepare the form data const formData = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { formData.append(key, value); }); // Create basic auth header const headers = { 'Content-Type': 'application/x-www-form-urlencoded', }; // Add Basic Auth if client credentials are provided if (params.client_id && params.client_secret) { try { // Create Basic Auth header const credentials = `${params.client_id}:${params.client_secret}`; let authHeader; if (typeof Buffer !== 'undefined') { // Node.js environment if (this.config.debug) { // Debug log removed } authHeader = `Basic ${Buffer.from(credentials).toString('base64')}`; } else { // Browser environment authHeader = `Basic ${btoa(credentials)}`; } headers.Authorization = authHeader; } catch (e) { this.logger.error('Error creating Basic Auth header', e); if (this.config.debug) { // Error log removed } // Fall back to sending credentials in body } } // Get the token endpoint from the OIDC configuration const tokenEndpoint = `${this.config.issuerBaseUrl}/oauth/token`; if (!tokenEndpoint) { throw new SchwabAuthError(AuthErrorCode.TOKEN_ENDPOINT_CONFIG_ERROR, 'Token endpoint not available in OIDC configuration'); } // Log request details when debug is enabled if (this.config.debug) { const redactedFormDataLog = new URLSearchParams(); formData.forEach((value, key) => { if (key === 'client_secret') { redactedFormDataLog.append(key, '[REDACTED]'); } else if (key === 'code') { // For code, show a portion to help with debugging const codePreview = value.substring(0, 10) + '...'; redactedFormDataLog.append(key, codePreview); // Add code-specific diagnostics this.logger.debug('[EnhancedTokenManager] Auth code diagnostics:'); this.logger.debug(` - Length: ${value.length}`); this.logger.debug(` - Preview: ${codePreview}`); this.logger.debug(` - Contains special chars: ${/[+/=@.]/g.test(value)}`); this.logger.debug(` - URL encoding check: ${encodeURIComponent(value) !== value ? 'Might need URL encoding' : 'Already encoded or no special chars'}`); } else { redactedFormDataLog.append(key, value); } }); const redactedHeaders = { ...headers }; if (redactedHeaders.Authorization) redactedHeaders.Authorization = 'Basic [REDACTED]'; this.logger.debug('[EnhancedTokenManager] Performing direct token exchange.'); this.logger.debug(` Endpoint: ${tokenEndpoint}`); this.logger.debug(' Method: POST'); this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); this.logger.debug(` Body: ${redactedFormDataLog.toString()}`); } // Make the token request with properly bound fetch to avoid "Illegal invocation" errors // We need to ensure fetch is bound correctly regardless of whether it's the global fetch or custom fetch let fetchFn; // Handle different ways fetch might be provided if (this.config.fetch === globalThis.fetch) { // Using the global fetch directly - bind to globalThis fetchFn = globalThis.fetch.bind(globalThis); } else if (typeof this.config.fetch === 'function') { // Using a custom fetch function - still needs binding to globalThis to avoid illegal invocation fetchFn = this.config.fetch.bind(globalThis); } else { // Fallback to global fetch in case something went wrong with config fetchFn = globalThis.fetch.bind(globalThis); } // Only perform URL-decoding while preserving structure for authorization code if (formData.get('grant_type') === 'authorization_code' && formData.has('code')) { const originalCode = formData.get('code') || ''; let processedCode = originalCode; // Only URL-decode specific encoded characters if present if (processedCode.includes('%')) { try { // Only handle specific known URL encodings to preserve structure processedCode = processedCode .replace(/%40/g, '@') // %40 = @ .replace(/%7E/g, '~') // %7E = ~ .replace(/%2B/g, '+') // %2B = + .replace(/%2F/g, '/') // %2F = / .replace(/%3D/g, '=') // %3D = = .replace(/%20/g, ' '); // %20 = space // DO NOT modify periods (%2E) or other structural elements if (this.config.debug) { this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] URL-decoded specific characters: '${originalCode.substring(0, 15)}...' => '${processedCode.substring(0, 15)}...'`); } } catch (e) { // If specific URL decoding fails, preserve original code this.logger.error(`[EnhancedTokenManager.performDirectTokenExchange] Error handling URL-encoded characters: ${e.message}`); processedCode = originalCode; // Revert to original } // Apply the changes if we made any if (processedCode !== originalCode) { formData.set('code', processedCode); if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.performDirectTokenExchange] Updated code with minimal URL-decoding while preserving structure'); } } } if (this.config.debug) { // Add some debugging info about the format for troubleshooting if (processedCode.includes('.')) { this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Authorization code contains periods. Format preserved as: ${processedCode .split('.') .map((segment) => segment.substring(0, 5) + '...') .join('.')}`); } this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Final authorization code (with minimal processing): '${processedCode.substring(0, 15)}...'`); } } // Additional debug logging before making the request if (this.config.debug) { this.logger.debug('[EnhancedTokenManager.performDirectTokenExchange] Making token request...'); this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Request body length: ${formData.toString().length}`); // Inspect grant_type and code specifically for debugging code exchange issues if (formData.get('grant_type') === 'authorization_code' && formData.has('code')) { const code = formData.get('code'); this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Code in request: length=${code?.length}, code=${code?.toString().substring(0, 15)}...`); this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Code validation: length is ${code?.length && code.length % 4 === 0 ? 'valid (multiple of 4)' : 'INVALID (not a multiple of 4)'}`); this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Is valid Base64 format: ${/^[A-Za-z0-9+/]*={0,2}$/.test(code || '') ? 'YES' : 'NO'}`); } if (formData.has('code_verifier')) { const verifier = formData.get('code_verifier'); this.logger.debug(`[EnhancedTokenManager.performDirectTokenExchange] Code verifier in request: length=${verifier?.length}, starts with=${verifier?.toString().substring(0, 10)}...`); } } const response = await fetchFn(tokenEndpoint, { method: 'POST', headers, body: formData.toString(), }); // Parse the response if (response.ok) { return await response.json(); } else { // Handle error response with improved error capture let errorBodyContent = 'Could not read error response body.'; let parsedErrorJson;