UNPKG

n8n-nodes-arubacentral

Version:

n8n community node for Aruba Central API integration with comprehensive monitoring, configuration, and management capabilities

656 lines (573 loc) 19.7 kB
// helpers/apiRequest.ts import { IExecuteFunctions, IHttpRequestOptions, NodeApiError, NodeOperationError, IDataObject, IHttpRequestMethods, ICredentialsHelper, ICredentialDataDecryptedObject, } from 'n8n-workflow'; import { logger } from './logger'; // Token cache with exponential backoff for rate limiting interface TokenCacheEntry { accessToken: string; refreshToken: string; expiresAt: number; retryAfter?: number; retryTimestamp?: number; } const tokenCache: Record<string, TokenCacheEntry> = {}; let sessionToken: string; let csrfToken: string; /** * Extract a token from cookies */ function extractTokenFromCookies(cookies: string[] | undefined, tokenName: string): string { if (!cookies || cookies.length === 0) { throw new Error(`No cookies found when extracting ${tokenName}`); } for (const cookie of cookies) { const match = new RegExp(`${tokenName}=([^;]+)`).exec(cookie); if (match) { return match[1]; } } throw new Error(`${tokenName} not found in cookies`); } /** * Check if in rate limit backoff period */ function isRateLimited(cacheKey: string): { isLimited: boolean; waitTime: number } { if (tokenCache[cacheKey]?.retryAfter && tokenCache[cacheKey]?.retryTimestamp) { const now = Date.now(); const retryTime = tokenCache[cacheKey].retryTimestamp! + tokenCache[cacheKey].retryAfter! * 1000; if (now < retryTime) { return { isLimited: true, waitTime: Math.ceil((retryTime - now) / 1000), }; } } return { isLimited: false, waitTime: 0 }; } /** * Handle rate limiting response */ function handleRateLimiting(cacheKey: string, response: any): void { // Parse retry time from response let retryAfter = 60; // Default 60 seconds try { // Check if retryAfter was directly provided if (response.retryAfter && typeof response.retryAfter === 'number') { retryAfter = response.retryAfter; } // Check for retry-after header else if (response.headers && response.headers['retry-after']) { retryAfter = parseInt(response.headers['retry-after'], 10); } // Try to parse from error message else { const message = response?.message || response?.error_description || ''; const match = message.match(/retry after (\d+) seconds/i) || message.match(/(\d+) seconds/); if (match && match[1]) { retryAfter = parseInt(match[1], 10); } } } catch (e) { logger.error('auth:ratelimit', `Failed to parse retry time: ${e.message}`); } // Store rate limit info in cache if (tokenCache[cacheKey]) { tokenCache[cacheKey].retryAfter = retryAfter; tokenCache[cacheKey].retryTimestamp = Date.now(); } else { tokenCache[cacheKey] = { accessToken: '', refreshToken: '', expiresAt: 0, retryAfter, retryTimestamp: Date.now(), }; } logger.warn('auth:ratelimit', `Rate limited. Will retry after ${retryAfter} seconds`); } /** * Get token data from n8n storage */ function getTokenFromCredentials(credentials: ICredentialDataDecryptedObject): { accessToken: string; refreshToken: string; expiresAt: number; } | null { logger.debug('auth:storage', 'Checking for token in n8n credential storage'); try { if (credentials.oauthTokenData) { const tokenData = credentials.oauthTokenData as IDataObject; logger.debug('auth:storage', 'Token data found in credentials'); if (tokenData.access_token && tokenData.expires_at) { logger.debug('auth:storage', 'Found access token and expiration'); return { accessToken: tokenData.access_token as string, refreshToken: (tokenData.refresh_token as string) || '', expiresAt: tokenData.expires_at as number, }; } } logger.debug('auth:storage', 'No valid token data found in credential storage'); return null; } catch (error) { logger.error('auth:storage', `Error retrieving token from credentials: ${error.message}`); return null; } } /** * Store OAuth token data in n8n credentials or workflow storage for persistence */ async function storeTokenData( this: IExecuteFunctions, tokenData: { access_token: string; refresh_token: string; expires_in: number; }, credentialId: string, ): Promise<void> { try { // Calculate expiration timestamp const expiresAt = Date.now() + tokenData.expires_in * 1000; // Format token data for storage const oauthTokenData = { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token, expires_in: tokenData.expires_in, expires_at: expiresAt, token_type: 'Bearer', }; // Try different methods to store the token based on available n8n APIs let stored = false; // Method 1: Try using nodeHelpers if available (newer n8n versions) try { if ( this.helpers.nodeHelpers && typeof this.helpers.nodeHelpers.updateCredentials === 'function' ) { await this.helpers.nodeHelpers.updateCredentials(credentialId, { oauthTokenData, }); logger.debug('auth:oauth2', 'Token stored using nodeHelpers.updateCredentials'); stored = true; } } catch (error) { logger.debug('auth:oauth2', `nodeHelpers.updateCredentials failed: ${error.message}`); } // Method 2: Try direct updateCredentials if available (older n8n versions) if (!stored && typeof this.helpers.updateCredentials === 'function') { try { await this.helpers.updateCredentials(credentialId, { oauthTokenData, }); logger.debug('auth:oauth2', 'Token stored using helpers.updateCredentials'); stored = true; } catch (error) { logger.debug('auth:oauth2', `helpers.updateCredentials failed: ${error.message}`); } } // Method 3: Fall back to workflow static data if credential update is not possible if (!stored && typeof this.getWorkflowStaticData === 'function') { const workflowStaticData = this.getWorkflowStaticData('node'); workflowStaticData.oauthTokenData = oauthTokenData; logger.debug('auth:oauth2', 'Token stored in workflow static data (fallback method)'); stored = true; } if (!stored) { logger.warn( 'auth:oauth2', 'Could not persist token data - no suitable storage method available', ); } } catch (error) { logger.error('auth:oauth2', `Failed to persist token data: ${error.message}`); // We continue without throwing as not persisting is better than failing completely } } /** * Check if a token is expired */ function isTokenExpired(expiresAt: number): boolean { // Consider token expired 60 seconds before actual expiration return Date.now() > expiresAt - 60000; } /** * Get an access token using the OAuth2 flow */ export async function getAccessToken( this: IExecuteFunctions, credentials: ICredentialDataDecryptedObject, ): Promise<string> { logger.debug('auth:oauth2', 'Getting access token...'); const cacheKey = `${credentials.baseUrl}_${credentials.clientId}`; const credentialId = credentials.$credentialId as string; // Check rate limiting first const rateLimitStatus = isRateLimited(cacheKey); if (rateLimitStatus.isLimited) { throw new Error( `API rate limited. Please try again after ${rateLimitStatus.waitTime} seconds.`, ); } // First try workflow static data as a persistent token source if (typeof this.getWorkflowStaticData === 'function') { const workflowStaticData = this.getWorkflowStaticData('node'); if (workflowStaticData.oauthTokenData) { const tokenData = workflowStaticData.oauthTokenData as IDataObject; if ( tokenData.access_token && tokenData.expires_at && Date.now() < (tokenData.expires_at as number) ) { logger.debug('auth:oauth2', 'Using valid token from workflow static data'); return tokenData.access_token as string; } else { logger.debug('auth:oauth2', 'Token in workflow static data is expired or invalid'); } } } // Second, try to get token from n8n credential storage const storedToken = getTokenFromCredentials(credentials); logger.debug( 'auth:oauth2', storedToken ? 'Found token in credential storage' : 'No token in credential storage', ); if (storedToken && !isTokenExpired(storedToken.expiresAt)) { logger.debug('auth:oauth2', 'Using valid access token from n8n credential storage'); // Update runtime cache for faster access next time tokenCache[cacheKey] = { accessToken: storedToken.accessToken, refreshToken: storedToken.refreshToken, expiresAt: storedToken.expiresAt, }; return storedToken.accessToken; } // Third, check in-memory cache if (tokenCache[cacheKey] && !isTokenExpired(tokenCache[cacheKey].expiresAt)) { logger.debug('auth:oauth2', 'Using valid token from in-memory cache'); return tokenCache[cacheKey].accessToken; } // Fourth, try to refresh the token if we have a refresh token const refreshToken = storedToken?.refreshToken || tokenCache[cacheKey]?.refreshToken; if (refreshToken) { logger.debug('auth:oauth2', 'Attempting to refresh token'); try { const refreshResponse = await this.helpers.request({ method: 'POST', uri: `${credentials.baseUrl}/oauth2/token`, qs: { client_id: credentials.clientId, client_secret: credentials.clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken, }, json: true, }); logger.debug('auth:oauth2', 'Token refresh successful'); // Format and store the new token const expiresAt = Date.now() + refreshResponse.expires_in * 1000; // Update the runtime cache tokenCache[cacheKey] = { accessToken: refreshResponse.access_token, refreshToken: refreshResponse.refresh_token, expiresAt, }; // Persist tokens await storeTokenData.call( this, { access_token: refreshResponse.access_token, refresh_token: refreshResponse.refresh_token, expires_in: refreshResponse.expires_in, }, credentialId, ); logger.debug('auth:oauth2', 'New token stored after refresh'); return refreshResponse.access_token; } catch (error) { logger.error('auth:oauth2', `Token refresh failed: ${error.message}`); logger.debug('auth:oauth2', 'Proceeding to full authentication flow'); // Check for rate limiting in refresh error if (error.statusCode === 429) { handleRateLimiting(cacheKey, error.error); throw new Error(`API rate limited during token refresh. Please try again later.`); } } } // Full OAuth flow logger.debug('auth:oauth2', 'Starting full OAuth flow...'); try { // Step 1: Login to obtain session and CSRF tokens. const loginUrl = `${credentials.baseUrl}/oauth2/authorize/central/api/login?client_id=${credentials.clientId}`; logger.debug('auth:oauth2', `Calling login endpoint: ${loginUrl}`); const loginResponse = await this.helpers.request({ method: 'POST', uri: loginUrl, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: { username: credentials.username, password: credentials.password, }, json: true, resolveWithFullResponse: true, }); logger.debug('auth:oauth2', 'Login response received'); // Extract tokens from response cookies. try { sessionToken = extractTokenFromCookies(loginResponse.headers['set-cookie'], 'session'); csrfToken = extractTokenFromCookies(loginResponse.headers['set-cookie'], 'csrftoken'); logger.debug('auth:oauth2', 'Session and CSRF tokens extracted successfully'); } catch (error) { logger.error('auth:oauth2', `Failed to extract tokens: ${error.message}`); throw new Error(`Authentication failed: ${error.message}`); } // Step 2: Generate authorization code. const authCodeUrl = `${credentials.baseUrl}/oauth2/authorize/central/api?client_id=${credentials.clientId}&response_type=code&scope=all`; logger.debug('auth:oauth2', `Calling authorization code endpoint: ${authCodeUrl}`); const authCodeResponse = await this.helpers.request({ method: 'POST', uri: authCodeUrl, headers: { 'Content-Type': 'application/json', Cookie: `session=${sessionToken}`, 'X-CSRF-Token': csrfToken, }, body: { customer_id: credentials.customerId, }, json: true, }); if (!authCodeResponse || !authCodeResponse.auth_code) { logger.error('auth:oauth2', 'No authorization code in response', authCodeResponse); throw new Error('Failed to obtain authorization code'); } const authorizationCode = authCodeResponse.auth_code; logger.debug('auth:oauth2', 'Authorization code obtained successfully'); // Step 3: Exchange the authorization code for an access token. const tokenUrl = `${credentials.baseUrl}/oauth2/token`; logger.debug('auth:oauth2', `Exchanging authorization code for access token at: ${tokenUrl}`); const tokenResponse = await this.helpers.request({ method: 'POST', uri: tokenUrl, headers: { 'Content-Type': 'application/json', }, body: { client_id: credentials.clientId, client_secret: credentials.clientSecret, grant_type: 'authorization_code', code: authorizationCode, }, json: true, }); if (!tokenResponse || !tokenResponse.access_token) { logger.error('auth:oauth2', 'No access token in response', tokenResponse); throw new Error('Failed to obtain access token'); } // Calculate expiration const expiresAt = Date.now() + tokenResponse.expires_in * 1000; // Store in runtime cache tokenCache[cacheKey] = { accessToken: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAt, }; // Store persistently await storeTokenData.call( this, { access_token: tokenResponse.access_token, refresh_token: tokenResponse.refresh_token, expires_in: tokenResponse.expires_in, }, credentialId, ); logger.debug('auth:oauth2', 'Full OAuth flow completed successfully'); return tokenResponse.access_token; } catch (error) { logger.error('auth:oauth2', `OAuth authentication failed: ${error.message}`); // Enhanced rate limiting detection if ( error.statusCode === 429 || (error.response?.body && typeof error.response.body === 'object' && error.response.body.message && error.response.body.message.includes('rate limit')) ) { // Extract retry time let retryAfter = '60'; let errorMsg = ''; if (error.response?.headers && error.response.headers['retry-after']) { retryAfter = error.response.headers['retry-after']; } else if (error.response?.body && typeof error.response.body === 'object') { errorMsg = error.response.body.message || ''; const match = errorMsg.match(/(\d+) seconds/); if (match && match[1]) { retryAfter = match[1]; } } else if (typeof error.error === 'object' && error.error.message) { errorMsg = error.error.message; const match = errorMsg.match(/(\d+) seconds/); if (match && match[1]) { retryAfter = match[1]; } } const retrySeconds = parseInt(retryAfter, 10) || 60; handleRateLimiting(cacheKey, { message: errorMsg, retryAfter: retrySeconds, }); throw new Error( `API rate limited during authentication. Please try again after ${retrySeconds} seconds.`, ); } throw new Error(`Authentication failed: ${error.message}`); } } /** * Make an API request to Aruba Central with OAuth2 authentication */ export async function apiRequest( this: IExecuteFunctions, method: IHttpRequestMethods, endpoint: string, body: IDataObject = {}, qs: IDataObject = {}, ): Promise<any> { logger.debug('api:request', `${method} ${endpoint} started`); logger.debug('api:request:params', JSON.stringify(qs, null, 2)); const credentials = await this.getCredentials('ArubaCentralOAuth2Api'); if (!credentials) { logger.error('api:request', 'No credentials provided'); throw new NodeOperationError(this.getNode(), 'No credentials provided'); } logger.debug('api:request', 'Credentials loaded successfully'); const baseUrl = credentials.baseUrl as string; // Print credential object structure (without sensitive values) const credentialKeys = Object.keys(credentials); logger.debug( 'api:request:credentials', `Available credential keys: ${JSON.stringify(credentialKeys)}`, ); if (credentials.oauthTokenData) { const tokenDataKeys = Object.keys(credentials.oauthTokenData as object); logger.debug('api:request:credentials', `Token data keys: ${JSON.stringify(tokenDataKeys)}`); } else { logger.debug('api:request:credentials', 'No oauthTokenData found in credentials'); } try { // Get access token using the OAuth2 flow logger.debug('api:request', 'Getting access token'); const accessToken = await getAccessToken.call(this, credentials); logger.debug('api:request', 'Successfully obtained access token'); // Make the actual API request logger.debug('api:request', `Making ${method} request to ${endpoint}`); const requestOptions: IHttpRequestOptions = { method, url: `${baseUrl}${endpoint}`, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body, qs, json: true, resolveWithFullResponse: true, }; logger.debug( 'api:request:options', JSON.stringify( { method: requestOptions.method, url: requestOptions.url, headers: { 'Content-Type': requestOptions.headers?.['Content-Type'], Authorization: '***', }, qs: requestOptions.qs, body: requestOptions.body, }, null, 2, ), ); // Use n8n's request helper const response = await this.helpers.httpRequest(requestOptions); logger.debug('api:request:response', `Response received`); return response.body || response; } catch (error) { logger.error('api:request:exception', error.message); // Handle API-specific errors if (error.response) { logger.error( 'api:request:error', `Status: ${error.statusCode}, Body: ${JSON.stringify(error.error)}`, ); let message = 'Unknown error'; const errorBody = error.error || {}; // More detailed error message extraction if (typeof errorBody === 'object') { if (errorBody.description) { message = errorBody.description; } else if (errorBody.error_description) { message = errorBody.error_description; } else if (errorBody.message) { message = errorBody.message; } else if (errorBody.error) { message = errorBody.error; } } else if (typeof errorBody === 'string') { try { const parsedBody = JSON.parse(errorBody); if (parsedBody.message) { message = parsedBody.message; } } catch (e) { // If parsing fails, use the string if it's not too long if (errorBody.length < 300) { message = errorBody; } } } logger.error('api:request:error', `Formatted message: ${message}`); // Handle rate limiting specifically if (error.statusCode === 429) { const cacheKey = `${credentials.baseUrl}_${credentials.clientId}`; // Extract retry time let retryAfter = '60'; if (error.response.headers && error.response.headers['retry-after']) { retryAfter = error.response.headers['retry-after']; } else if (message.includes('seconds')) { const match = message.match(/(\d+) seconds/); if (match && match[1]) { retryAfter = match[1]; } } // Use the extracted retry time or a default const retrySeconds = parseInt(retryAfter, 10) || 60; handleRateLimiting(cacheKey, { message, retryAfter: retrySeconds, }); throw new Error(`API rate limited. Please try again after ${retrySeconds} seconds.`); } throw new NodeApiError(this.getNode(), error, { message }); } // Re-throw with original error logger.error('api:request:error', 'Re-throwing original error'); throw error; } finally { logger.debug('api:request', 'Request completed'); } }