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
text/typescript
// 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');
}
}