UNPKG

autotrader-connect-api

Version:

Production-ready TypeScript wrapper for Auto Trader UK Connect APIs

325 lines 10.6 kB
"use strict"; /** * Authentication module for AutoTrader API * Handles OAuth2 client credentials flow with token caching and refresh */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getToken = exports.getAuthManager = exports.AuthManager = void 0; const axios_1 = __importDefault(require("axios")); /** * Token refresh buffer time in seconds (refresh tokens 5 minutes before expiry) */ const TOKEN_REFRESH_BUFFER = 300; /** * Maximum retry attempts for token refresh */ const MAX_RETRY_ATTEMPTS = 3; /** * Authentication manager class */ class AuthManager { constructor(credentials, baseURL = process.env['AT_BASE_URL'] || 'https://api.autotrader.co.uk') { this.refreshPromise = null; this.credentials = credentials; this.tokenEndpoint = `${baseURL}/authenticate`; this.state = { isAuthenticated: false, token: null, lastRefresh: null, refreshInProgress: false, }; } /** * Get a valid access token, refreshing if necessary * Ensures concurrency safety by queuing multiple calls */ async getToken() { // Check if we have a valid token const validation = this.validateToken(); if (validation.isValid && !validation.shouldRefresh) { return this.state.token.accessToken; } // If refresh is already in progress, wait for it if (this.refreshPromise) { return await this.refreshPromise; } // Start token refresh this.refreshPromise = this.refreshToken(); try { const token = await this.refreshPromise; return token; } finally { this.refreshPromise = null; } } /** * Force refresh the access token */ async refreshToken() { this.state.refreshInProgress = true; try { const tokenResponse = await this.requestToken(); this.storeToken(tokenResponse); return this.state.token.accessToken; } catch (error) { this.handleAuthError(error); throw error; } finally { this.state.refreshInProgress = false; } } /** * Validate the current token */ validateToken() { if (!this.state.token) { return { isValid: false, expiresIn: 0, shouldRefresh: true, error: { type: 'TOKEN_EXPIRED', message: 'No token available', }, }; } const now = Math.floor(Date.now() / 1000); const expiresIn = this.state.token.expiresAt - now; const shouldRefresh = expiresIn <= TOKEN_REFRESH_BUFFER; const isValid = expiresIn > 0; return { isValid, expiresIn, shouldRefresh, }; } /** * Get the current authentication state */ getAuthState() { return { ...this.state }; } /** * Clear the stored token (logout) */ clearToken() { this.state = { isAuthenticated: false, token: null, lastRefresh: null, refreshInProgress: false, }; } /** * Check if currently authenticated */ isAuthenticated() { const validation = this.validateToken(); return validation.isValid; } /** * Request a new token from the API */ async requestToken(retryCount = 0) { try { const response = await axios_1.default.post(this.tokenEndpoint, new URLSearchParams({ grant_type: 'client_credentials', client_id: this.credentials.apiKey, client_secret: this.credentials.apiSecret, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, timeout: 30000, }); if (!response.data.access_token) { throw new Error('Invalid token response: missing access_token'); } return response.data; } catch (error) { if (retryCount < MAX_RETRY_ATTEMPTS && this.shouldRetry(error)) { const delay = Math.pow(2, retryCount) * 1000; // Exponential backoff await this.sleep(delay); return this.requestToken(retryCount + 1); } throw this.createAuthError(error); } } /** * Store the token and update authentication state */ storeToken(tokenResponse) { const now = Math.floor(Date.now() / 1000); this.state.token = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, expiresIn: tokenResponse.expires_in, issuedAt: now, expiresAt: now + tokenResponse.expires_in, ...(tokenResponse.scope && { scope: tokenResponse.scope }), ...(tokenResponse.refresh_token && { refreshToken: tokenResponse.refresh_token }), }; this.state.isAuthenticated = true; this.state.lastRefresh = now; } /** * Handle authentication errors */ handleAuthError(error) { console.error('Authentication error:', error); // Clear token on authentication failure if (this.isAuthenticationError(error)) { this.clearToken(); } } /** * Create a standardized auth error */ createAuthError(error) { if (axios_1.default.isAxiosError(error)) { const axiosError = error; const authError = { type: this.getErrorType(axiosError), message: this.getErrorMessage(axiosError), originalError: error, }; if (axiosError.response?.status) { authError.statusCode = axiosError.response.status; } const retryAfter = this.getRetryAfter(axiosError); if (retryAfter) { authError.retryAfter = retryAfter; } return authError; } const authError = { type: 'NETWORK_ERROR', message: error instanceof Error ? error.message : 'Unknown authentication error', }; if (error instanceof Error) { authError.originalError = error; } return authError; } /** * Determine error type from axios error */ getErrorType(error) { if (!error.response) { return 'NETWORK_ERROR'; } switch (error.response.status) { case 401: return 'INVALID_CREDENTIALS'; case 429: return 'RATE_LIMITED'; default: return 'INVALID_RESPONSE'; } } /** * Get error message from axios error */ getErrorMessage(error) { if (error.response?.data && typeof error.response.data === 'object') { const data = error.response.data; return data.error_description || data.message || data.error || error.message; } return error.message; } /** * Extract retry-after header value */ getRetryAfter(error) { const retryAfter = error.response?.headers['retry-after']; if (retryAfter && typeof retryAfter === 'string') { const seconds = parseInt(retryAfter, 10); return isNaN(seconds) ? undefined : seconds; } return undefined; } /** * Check if error indicates invalid credentials */ isAuthenticationError(error) { if (axios_1.default.isAxiosError(error)) { return error.response?.status === 401; } return false; } /** * Check if error should trigger a retry */ shouldRetry(error) { if (axios_1.default.isAxiosError(error)) { const status = error.response?.status; // Retry on network errors, 5xx errors, but not on 4xx (except 429) return !status || status >= 500 || status === 429; } return true; // Retry on non-axios errors } /** * Sleep for specified milliseconds */ sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } exports.AuthManager = AuthManager; /** * Default auth manager instance */ let defaultAuthManager = null; /** * Get or create the default auth manager */ function getAuthManager(credentials, baseURL) { if (!defaultAuthManager) { if (!credentials) { // Determine if we should use sandbox const useSandbox = process.env['AT_USE_SANDBOX'] === 'true' || process.env['NODE_ENV'] === 'development' || process.env['NODE_ENV'] === 'test'; let apiKey; let apiSecret; if (useSandbox) { apiKey = process.env['AT_SANDBOX_API_KEY']; apiSecret = process.env['AT_SANDBOX_API_SECRET']; } else { apiKey = process.env['AT_API_KEY']; apiSecret = process.env['AT_API_SECRET']; } if (!apiKey || !apiSecret) { const envPrefix = useSandbox ? 'AT_SANDBOX_' : 'AT_'; throw new Error(`Authentication credentials required. Provide credentials or set ${envPrefix}API_KEY and ${envPrefix}API_SECRET environment variables. ` + `Current environment: ${useSandbox ? 'sandbox' : 'production'}`); } credentials = { apiKey, apiSecret }; // Use sandbox base URL if not provided and we're in sandbox mode if (!baseURL && useSandbox) { baseURL = process.env['AT_SANDBOX_BASE_URL'] || 'https://sandbox-api.autotrader.co.uk'; } } defaultAuthManager = new AuthManager(credentials, baseURL); } return defaultAuthManager; } exports.getAuthManager = getAuthManager; /** * Get a valid token using the default auth manager */ async function getToken() { const authManager = getAuthManager(); return authManager.getToken(); } exports.getToken = getToken; //# sourceMappingURL=auth.js.map