@reportportal/client-javascript
Version:
ReportPortal client for Node.js
227 lines (197 loc) • 7.66 kB
JavaScript
const axios = require('axios');
const { getProxyAgentForUrl } = require('./proxyHelper');
const TOKEN_REFRESH_THRESHOLD_MS = 60000;
const DEFAULT_TOKEN_EXPIRATION_MS = 3600000; // 1 hour in milliseconds
const SECOND_IN_MS = 1000;
const GRANT_TYPE_PASSWORD = 'password';
const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';
class OAuthInterceptor {
/**
* OAuth 2.0 Password Grant Flow Interceptor
* @param {Object} config - OAuth configuration
* @param {string} config.tokenEndpoint - OAuth token endpoint URL
* @param {string} config.username - Username for password grant
* @param {string} config.password - Password for password grant
* @param {string} config.clientId - OAuth client ID
* @param {string} [config.clientSecret] - OAuth client secret (optional)
* @param {string} [config.scope] - OAuth scope (optional)
* @param {boolean} [config.debug] - Enable debug logging
* @param {Object} [config.restClientConfig] - REST client configuration for proxy support
*/
constructor(config) {
this.tokenEndpoint = config.tokenEndpoint;
this.username = config.username;
this.password = config.password;
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.scope = config.scope;
this.restClientConfig = config.restClientConfig || {};
this.debug = this.restClientConfig.debug || false;
this.accessToken = null;
this.refreshToken = null;
this.tokenExpiresAt = null;
this.tokenRenewPromise = null;
}
logDebug(message, data = '') {
if (this.debug) {
console.log(`[OAuth] ${message}`, data);
}
}
/**
* Obtains or refreshes the access token
* @returns {Promise<string>} Access token
*/
async getAccessToken() {
if (this.tokenRenewPromise) {
this.logDebug('Waiting for ongoing token refresh');
return this.tokenRenewPromise;
}
if (this.accessToken && this.tokenExpiresAt) {
const now = Date.now();
const timeUntilExpiry = this.tokenExpiresAt - now;
if (timeUntilExpiry > TOKEN_REFRESH_THRESHOLD_MS) {
this.logDebug('Using existing valid token');
return this.accessToken;
}
this.logDebug('Token expiring soon, refreshing');
}
this.tokenRenewPromise = this.renewToken();
try {
const token = await this.tokenRenewPromise;
return token;
} catch (error) {
this.logDebug('Error during token refresh', error);
throw error;
} finally {
this.tokenRenewPromise = null;
}
}
/**
* Refreshes the access token using password grant or refresh token grant
* @returns {Promise<string>} Access token
*/
async renewToken() {
try {
return await this.requestToken(
this.refreshToken ? GRANT_TYPE_REFRESH_TOKEN : GRANT_TYPE_PASSWORD,
);
} catch (error) {
// If refresh token grant failed, try password grant as fallback
if (this.refreshToken) {
this.logDebug('Refresh token failed, falling back to password grant');
console.warn(
'[OAuth] Refresh token expired or invalid, re-authenticating with password grant',
);
this.refreshToken = null; // Clear invalid refresh token
try {
return await this.requestToken(GRANT_TYPE_PASSWORD);
} catch (fallbackError) {
const errorMessage = fallbackError.response
? `OAuth password grant fallback failed: ${
fallbackError.response.status
} - ${JSON.stringify(fallbackError.response.data)}`
: `OAuth password grant fallback failed: ${fallbackError.message}`;
console.error(`[OAuth] ${errorMessage}`);
throw new Error(errorMessage);
}
}
// No fallback available, rethrow original error
const errorMessage = error.response
? `OAuth token request failed: ${error.response.status} - ${JSON.stringify(
error.response.data,
)}`
: `OAuth token request failed: ${error.message}`;
console.error(`[OAuth] ${errorMessage}`);
throw new Error(errorMessage);
}
}
/**
* Requests a token using the specified grant type
* @param {string} grantType - Either 'password' or 'refresh_token'
* @returns {Promise<string>} Access token
* @private
*/
async requestToken(grantType) {
const params = new URLSearchParams();
params.append('client_id', this.clientId);
params.append('grant_type', grantType);
if (grantType === GRANT_TYPE_REFRESH_TOKEN) {
this.logDebug('Requesting new access token using refresh_token');
params.append('refresh_token', this.refreshToken);
} else {
this.logDebug('Requesting access token using username and password');
params.append('username', this.username);
params.append('password', this.password);
}
if (this.clientSecret) {
params.append('client_secret', this.clientSecret);
}
if (this.scope) {
params.append('scope', this.scope);
}
// Get proxy agent for token endpoint
// Only apply if custom agents are not explicitly provided
const hasCustomAgents = this.restClientConfig.httpsAgent || this.restClientConfig.httpAgent;
const proxyAgents = hasCustomAgents
? {}
: getProxyAgentForUrl(this.tokenEndpoint, this.restClientConfig);
const usingProxyAgent = Object.keys(proxyAgents).length > 0;
if (this.debug && usingProxyAgent) {
this.logDebug(`Making token request to ${this.tokenEndpoint} with proxy agent`);
}
const response = await axios.post(this.tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
...proxyAgents,
// Custom agents from restClientConfig (if provided) take precedence
...(this.restClientConfig.httpsAgent && { httpsAgent: this.restClientConfig.httpsAgent }),
...(this.restClientConfig.httpAgent && { httpAgent: this.restClientConfig.httpAgent }),
// Explicitly disable axios built-in proxy when using custom agents
...(usingProxyAgent && { proxy: false }),
});
const {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresIn,
} = response.data;
if (!accessToken) {
throw new Error('No access token received from OAuth server');
}
this.accessToken = accessToken;
if (refreshToken) {
this.refreshToken = refreshToken;
this.logDebug('Refresh token stored for future use');
}
if (expiresIn) {
this.tokenExpiresAt = Date.now() + expiresIn * SECOND_IN_MS;
this.logDebug(`Token obtained, expires in ${expiresIn} seconds`);
} else {
this.tokenExpiresAt = Date.now() + DEFAULT_TOKEN_EXPIRATION_MS;
this.logDebug('Token obtained, no expiration provided, assuming 1 hour');
}
return this.accessToken;
}
/**
* Attaches the interceptor to an axios instance
* @param {Object} axiosInstance - Axios instance to attach interceptor to
*/
attach(axiosInstance) {
axiosInstance.interceptors.request.use(
async (config) => {
try {
const token = await this.getAccessToken();
// eslint-disable-next-line no-param-reassign
config.headers.Authorization = `Bearer ${token}`;
this.logDebug(`Request to ${config.url} with OAuth token`);
return config;
} catch (error) {
console.error('[OAuth] Failed to obtain access token, request may fail:', error.message);
return config;
}
},
(error) => Promise.reject(error),
);
}
}
module.exports = OAuthInterceptor;