autotrader-connect-api
Version:
Production-ready TypeScript wrapper for Auto Trader UK Connect APIs
316 lines • 10.1 kB
JavaScript
/**
* Authentication module for AutoTrader API
* Handles OAuth2 client credentials flow with token caching and refresh
*/
import axios from '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
*/
export 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.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.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.isAxiosError(error)) {
return error.response?.status === 401;
}
return false;
}
/**
* Check if error should trigger a retry
*/
shouldRetry(error) {
if (axios.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));
}
}
/**
* Default auth manager instance
*/
let defaultAuthManager = null;
/**
* Get or create the default auth manager
*/
export 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;
}
/**
* Get a valid token using the default auth manager
*/
export async function getToken() {
const authManager = getAuthManager();
return authManager.getToken();
}
//# sourceMappingURL=auth.js.map