From 1456e284f45f46e97b6607d18f87980dedeed2e2 Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Thu, 16 Oct 2025 18:34:27 +0300 Subject: [PATCH 1/7] EPMRPP-108464 || OAuth password grant type support --- README.md | 52 +++++++++++- index.d.ts | 46 ++++++++++- lib/commons/config.js | 53 +++++++++++- lib/oauth.js | 157 ++++++++++++++++++++++++++++++++++++ lib/report-portal-client.js | 15 +++- lib/rest.js | 17 ++++ 6 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 lib/oauth.js diff --git a/README.md b/README.md index 85c678b5..ef9debfc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ The latest version is available on npm: npm install @reportportal/client-javascript ``` -## Usage example +## Usage examples + +### Using API Key Authentication ```javascript const RPClient = require('@reportportal/client-javascript'); @@ -48,13 +50,59 @@ rpClient.checkConnect().then(() => { }); ``` +### Using OAuth 2.0 Password Grant + +```javascript +const RPClient = require('@reportportal/client-javascript'); + +const rpClient = new RPClient({ + tokenEndpoint: 'https://your-oauth-server.com/oauth/token', + username: 'your-username', + password: 'your-password', + clientId: 'your-client-id', + clientSecret: 'your-client-secret', // optional + scope: 'reportportal', // optional + endpoint: 'http://your-instance.com:8080/api/v1', + launch: 'LAUNCH_NAME', + project: 'PROJECT_NAME' +}); + +rpClient.checkConnect().then(() => { + console.log('You have successfully connected to the server.'); +}, (error) => { + console.log('Error connection to server'); + console.dir(error); +}); +``` + +**Note:** The OAuth interceptor automatically handles token refresh when the token is about to expire (1 minute before expiration). + ## Configuration When creating a client instance, you need to specify the following options: +### Authentication Options + +The client supports two authentication methods: +1. **API Key Authentication** (default) +2. **OAuth 2.0 Password Grant** (recommended for enhanced security) + +**Note:** If both authentication methods are provided, OAuth 2.0 will be used. + +| Option | Necessity | Default | Description | +|-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| apiKey | Required* | | User's reportportal token from which you want to send requests. It can be found on the profile page of this user. *Required only if OAuth is not configured. | +| tokenEndpoint | Optional** | | OAuth 2.0 token endpoint URL for password grant flow. **Required for OAuth authentication. | +| username | Optional** | | Username for OAuth 2.0 password grant. **Required for OAuth authentication. | +| password | Optional** | | Password for OAuth 2.0 password grant. **Required for OAuth authentication. | +| clientId | Optional** | | OAuth 2.0 client ID. **Required for OAuth authentication. | +| clientSecret | Optional | | OAuth 2.0 client secret (optional, depending on your OAuth server configuration). | +| scope | Optional | | OAuth 2.0 scope (optional, space-separated list of scopes). | + +### General Options + | Option | Necessity | Default | Description | |-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| apiKey | Required | | User's reportportal token from which you want to send requests. It can be found on the profile page of this user. | | endpoint | Required | | URL of your server. For example, if you visit the page at 'https://server:8080/ui', then endpoint will be equal to 'https://server:8080/api/v1'. | | launch | Required | | Name of the launch at creation. | | project | Required | | The name of the project in which the launches will be created. | diff --git a/index.d.ts b/index.d.ts index fd974fe4..8c2df53f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,7 +2,7 @@ declare module '@reportportal/client-javascript' { /** * Configuration options for initializing the Report Portal client. * - * @example + * @example API Key Authentication * ```typescript * const rp = new ReportPortalClient({ * endpoint: 'https://your.reportportal.server/api/v1', @@ -10,9 +10,23 @@ declare module '@reportportal/client-javascript' { * apiKey: 'your_api_key', * }); * ``` + * + * @example OAuth 2.0 Authentication + * ```typescript + * const rp = new ReportPortalClient({ + * endpoint: 'https://your.reportportal.server/api/v1', + * project: 'your_project_name', + * tokenEndpoint: 'https://your-oauth-server.com/oauth/token', + * username: 'your-username', + * password: 'your-password', + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', // optional + * scope: 'reportportal', // optional + * }); + * ``` */ export interface ReportPortalConfig { - apiKey: string; + apiKey?: string; endpoint: string; launch: string; project: string; @@ -23,6 +37,34 @@ declare module '@reportportal/client-javascript' { launchUuidPrintOutput?: string; restClientConfig?: Record; token?: string; + /** + * OAuth 2.0 token endpoint URL for password grant flow. + * Required for OAuth authentication. + */ + tokenEndpoint?: string; + /** + * Username for OAuth 2.0 password grant. + * Required for OAuth authentication. + */ + username?: string; + /** + * Password for OAuth 2.0 password grant. + * Required for OAuth authentication. + */ + password?: string; + /** + * OAuth 2.0 client ID. + * Required for OAuth authentication. + */ + clientId?: string; + /** + * OAuth 2.0 client secret (optional, depending on your OAuth server configuration). + */ + clientSecret?: string; + /** + * OAuth 2.0 scope (optional, space-separated list of scopes). + */ + scope?: string; } /** diff --git a/lib/commons/config.js b/lib/commons/config.js index a09b468f..82a22ea1 100644 --- a/lib/commons/config.js +++ b/lib/commons/config.js @@ -31,13 +31,62 @@ const getApiKey = ({ apiKey, token }) => { return calculatedApiKey; }; +const getOAuthConfig = (options) => { + const { tokenEndpoint, username, password, clientId, clientSecret, scope } = options; + + // Check if any OAuth params are provided + if (!tokenEndpoint && !username && !password && !clientId) { + return null; + } + + // Validate required OAuth parameters + if (!tokenEndpoint) { + throw new ReportPortalRequiredOptionError('tokenEndpoint'); + } + if (!username) { + throw new ReportPortalRequiredOptionError('username'); + } + if (!password) { + throw new ReportPortalRequiredOptionError('password'); + } + if (!clientId) { + throw new ReportPortalRequiredOptionError('clientId'); + } + + return { + tokenEndpoint, + username, + password, + clientId, + clientSecret, + scope, + }; +}; + const getClientConfig = (options) => { let calculatedOptions = options; try { if (typeof options !== 'object') { throw new ReportPortalValidationError('`options` must be an object.'); } - const apiKey = getApiKey(options); + + // Try to get OAuth config first + const oauthConfig = getOAuthConfig(options); + + // If OAuth is not configured, apiKey is required + let apiKey; + if (!oauthConfig) { + apiKey = getApiKey(options); + } else { + // If OAuth is configured, apiKey is optional (use it if provided) + try { + apiKey = getApiKey(options); + } catch (error) { + // Ignore error if OAuth is configured + apiKey = null; + } + } + const project = getRequiredOption(options, 'project'); const endpoint = getRequiredOption(options, 'endpoint'); @@ -52,6 +101,7 @@ const getClientConfig = (options) => { calculatedOptions = { apiKey, + oauthConfig, project, endpoint, launch: options.launch, @@ -78,4 +128,5 @@ module.exports = { getClientConfig, getRequiredOption, getApiKey, + getOAuthConfig, }; diff --git a/lib/oauth.js b/lib/oauth.js new file mode 100644 index 00000000..e92e3eba --- /dev/null +++ b/lib/oauth.js @@ -0,0 +1,157 @@ +const axios = require('axios'); + +const TOKEN_REFRESH_THRESHOLD_MS = 60000; // 1 minute in advance + +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 + */ + 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.debug = config.debug || false; + + this.accessToken = null; + this.tokenExpiresAt = null; + this.tokenRefreshPromise = null; + } + + logDebug(message, data = '') { + if (this.debug) { + console.log(`[OAuth] ${message}`, data); + } + } + + /** + * Obtains or refreshes the access token + * @returns {Promise} Access token + */ + async getAccessToken() { + // If already refreshing, wait for that promise + if (this.tokenRefreshPromise) { + this.logDebug('Waiting for ongoing token refresh'); + return this.tokenRefreshPromise; + } + + // Check if current token is valid and not expiring soon + 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'); + } + + // Start token refresh + this.tokenRefreshPromise = this.refreshToken(); + + try { + const token = await this.tokenRefreshPromise; + return token; + } catch (error) { + this.logDebug('Error during token refresh', error); + throw error; + } finally { + this.tokenRefreshPromise = null; + } + } + + /** + * Refreshes the access token using password grant + * @returns {Promise} Access token + */ + async refreshToken() { + try { + this.logDebug('Requesting new access token'); + + const params = new URLSearchParams(); + params.append('grant_type', 'password'); + params.append('username', this.username); + params.append('password', this.password); + params.append('client_id', this.clientId); + + if (this.clientSecret) { + params.append('client_secret', this.clientSecret); + } + + if (this.scope) { + params.append('scope', this.scope); + } + + const response = await axios.post(this.tokenEndpoint, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const { access_token: accessToken, expires_in: expiresIn } = response.data; + + if (!accessToken) { + throw new Error('No access token received from OAuth server'); + } + + this.accessToken = accessToken; + + // Calculate token expiration time + if (expiresIn) { + this.tokenExpiresAt = Date.now() + expiresIn * 1000; + this.logDebug(`Token obtained, expires in ${expiresIn} seconds`); + } else { + // If no expiration provided, assume token is valid for 1 hour + this.tokenExpiresAt = Date.now() + 3600000; + this.logDebug('Token obtained, no expiration provided, assuming 1 hour'); + } + + return this.accessToken; + } catch (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); + } + } + + /** + * 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) { + // Log error but don't throw to not break the request flow + console.error('[OAuth] Failed to obtain access token, request may fail:', error.message); + return config; + } + }, + (error) => Promise.reject(error), + ); + } +} + +module.exports = OAuthInterceptor; + diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 9a2cdb9b..7c83a268 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -39,17 +39,28 @@ class RPClient { this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); - this.headers = { + + // Determine authentication method: OAuth takes precedence over API key + const headers = { 'User-Agent': 'NodeJS', 'Content-Type': 'application/json; charset=UTF-8', - Authorization: `Bearer ${this.apiKey}`, ...(this.config.headers || {}), }; + + // Only set API key header if OAuth is not configured + // OAuth interceptor will handle Authorization header when configured + if (!this.config.oauthConfig && this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; + } + + this.headers = headers; this.helpers = helpers; this.restClient = new RestClient({ baseURL: this.baseURL, headers: this.headers, restClientConfig: this.config.restClientConfig, + oauthConfig: this.config.oauthConfig, + debug: this.debug, }); this.statistics = new Statistics(EVENT_NAME, agentParams); this.launchUuid = ''; diff --git a/lib/rest.js b/lib/rest.js index a065e4d9..5aa49414 100644 --- a/lib/rest.js +++ b/lib/rest.js @@ -3,6 +3,7 @@ const axiosRetry = require('axios-retry').default; const http = require('http'); const https = require('https'); const logger = require('./logger'); +const OAuthInterceptor = require('./oauth'); const DEFAULT_MAX_CONNECTION_TIME_MS = 30000; const DEFAULT_RETRY_ATTEMPTS = 6; @@ -22,6 +23,8 @@ class RestClient { this.baseURL = options.baseURL; this.headers = options.headers; this.restClientConfig = options.restClientConfig; + this.oauthConfig = options.oauthConfig; + this.debug = options.debug; this.axiosInstance = axios.create({ timeout: DEFAULT_MAX_CONNECTION_TIME_MS, @@ -29,6 +32,20 @@ class RestClient { ...this.getRestConfig(this.restClientConfig), }); + // Create and attach OAuth interceptor if OAuth config is provided + // Must be before retry to ensure token is fresh on each retry + if (this.oauthConfig) { + try { + const oauthInterceptor = new OAuthInterceptor({ + ...this.oauthConfig, + debug: this.debug, + }); + oauthInterceptor.attach(this.axiosInstance); + } catch (error) { + console.error('[RestClient] Failed to initialize OAuth interceptor:', error.message); + } + } + axiosRetry(this.axiosInstance, this.getRetryConfig()); if (this.restClientConfig?.debug) { From 586f99713b340dd5d68fec9caa6284ee6a6972bd Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Fri, 17 Oct 2025 16:27:40 +0300 Subject: [PATCH 2/7] EPMRPP-108464 || Update config structure. Remove redundant comments --- README.md | 32 +++++++++++------ index.d.ts | 72 ++++++++++++++++++++----------------- lib/commons/config.js | 19 +++++++--- lib/oauth.js | 7 +--- lib/report-portal-client.js | 1 - 5 files changed, 76 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ef9debfc..dc468156 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,17 @@ rpClient.checkConnect().then(() => { const RPClient = require('@reportportal/client-javascript'); const rpClient = new RPClient({ - tokenEndpoint: 'https://your-oauth-server.com/oauth/token', - username: 'your-username', - password: 'your-password', - clientId: 'your-client-id', - clientSecret: 'your-client-secret', // optional - scope: 'reportportal', // optional endpoint: 'http://your-instance.com:8080/api/v1', launch: 'LAUNCH_NAME', - project: 'PROJECT_NAME' + project: 'PROJECT_NAME', + oauth: { + tokenEndpoint: 'https://your-oauth-server.com/oauth/token', + username: 'your-username', + password: 'your-password', + clientId: 'your-client-id', + clientSecret: 'your-client-secret', // optional + scope: 'reportportal', // optional + } }); rpClient.checkConnect().then(() => { @@ -92,10 +94,18 @@ The client supports two authentication methods: | Option | Necessity | Default | Description | |-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | apiKey | Required* | | User's reportportal token from which you want to send requests. It can be found on the profile page of this user. *Required only if OAuth is not configured. | -| tokenEndpoint | Optional** | | OAuth 2.0 token endpoint URL for password grant flow. **Required for OAuth authentication. | -| username | Optional** | | Username for OAuth 2.0 password grant. **Required for OAuth authentication. | -| password | Optional** | | Password for OAuth 2.0 password grant. **Required for OAuth authentication. | -| clientId | Optional** | | OAuth 2.0 client ID. **Required for OAuth authentication. | +| oauth | Optional | | OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. See OAuth Configuration below. | + +#### OAuth Configuration + +The `oauth` object supports the following properties: + +| Property | Necessity | Default | Description | +|-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| tokenEndpoint | Required | | OAuth 2.0 token endpoint URL for password grant flow. | +| username | Required | | Username for OAuth 2.0 password grant. | +| password | Required | | Password for OAuth 2.0 password grant. | +| clientId | Required | | OAuth 2.0 client ID. | | clientSecret | Optional | | OAuth 2.0 client secret (optional, depending on your OAuth server configuration). | | scope | Optional | | OAuth 2.0 scope (optional, space-separated list of scopes). | diff --git a/index.d.ts b/index.d.ts index 8c2df53f..314059a2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,34 @@ declare module '@reportportal/client-javascript' { + /** + * OAuth 2.0 configuration for password grant flow. + */ + export interface OAuthConfig { + /** + * OAuth 2.0 token endpoint URL for password grant flow. + */ + tokenEndpoint: string; + /** + * Username for OAuth 2.0 password grant. + */ + username: string; + /** + * Password for OAuth 2.0 password grant. + */ + password: string; + /** + * OAuth 2.0 client ID. + */ + clientId: string; + /** + * OAuth 2.0 client secret (optional, depending on your OAuth server configuration). + */ + clientSecret?: string; + /** + * OAuth 2.0 scope (optional, space-separated list of scopes). + */ + scope?: string; + } + /** * Configuration options for initializing the Report Portal client. * @@ -16,12 +46,14 @@ declare module '@reportportal/client-javascript' { * const rp = new ReportPortalClient({ * endpoint: 'https://your.reportportal.server/api/v1', * project: 'your_project_name', - * tokenEndpoint: 'https://your-oauth-server.com/oauth/token', - * username: 'your-username', - * password: 'your-password', - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', // optional - * scope: 'reportportal', // optional + * oauth: { + * tokenEndpoint: 'https://your-oauth-server.com/oauth/token', + * username: 'your-username', + * password: 'your-password', + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', // optional + * scope: 'reportportal', // optional + * } * }); * ``` */ @@ -38,33 +70,9 @@ declare module '@reportportal/client-javascript' { restClientConfig?: Record; token?: string; /** - * OAuth 2.0 token endpoint URL for password grant flow. - * Required for OAuth authentication. + * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. */ - tokenEndpoint?: string; - /** - * Username for OAuth 2.0 password grant. - * Required for OAuth authentication. - */ - username?: string; - /** - * Password for OAuth 2.0 password grant. - * Required for OAuth authentication. - */ - password?: string; - /** - * OAuth 2.0 client ID. - * Required for OAuth authentication. - */ - clientId?: string; - /** - * OAuth 2.0 client secret (optional, depending on your OAuth server configuration). - */ - clientSecret?: string; - /** - * OAuth 2.0 scope (optional, space-separated list of scopes). - */ - scope?: string; + oauth?: OAuthConfig; } /** diff --git a/lib/commons/config.js b/lib/commons/config.js index 82a22ea1..42ace0e2 100644 --- a/lib/commons/config.js +++ b/lib/commons/config.js @@ -32,7 +32,16 @@ const getApiKey = ({ apiKey, token }) => { }; const getOAuthConfig = (options) => { - const { tokenEndpoint, username, password, clientId, clientSecret, scope } = options; + // Support both nested oauth object and flat structure + let oauthParams = options.oauth || {}; + + // If oauth object doesn't exist, check for flat structure (backward compatibility) + if (!options.oauth) { + const { tokenEndpoint, username, password, clientId, clientSecret, scope } = options; + oauthParams = { tokenEndpoint, username, password, clientId, clientSecret, scope }; + } + + const { tokenEndpoint, username, password, clientId, clientSecret, scope } = oauthParams; // Check if any OAuth params are provided if (!tokenEndpoint && !username && !password && !clientId) { @@ -41,16 +50,16 @@ const getOAuthConfig = (options) => { // Validate required OAuth parameters if (!tokenEndpoint) { - throw new ReportPortalRequiredOptionError('tokenEndpoint'); + throw new ReportPortalRequiredOptionError('oauth.tokenEndpoint'); } if (!username) { - throw new ReportPortalRequiredOptionError('username'); + throw new ReportPortalRequiredOptionError('oauth.username'); } if (!password) { - throw new ReportPortalRequiredOptionError('password'); + throw new ReportPortalRequiredOptionError('oauth.password'); } if (!clientId) { - throw new ReportPortalRequiredOptionError('clientId'); + throw new ReportPortalRequiredOptionError('oauth.clientId'); } return { diff --git a/lib/oauth.js b/lib/oauth.js index e92e3eba..3d21a424 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -1,6 +1,6 @@ const axios = require('axios'); -const TOKEN_REFRESH_THRESHOLD_MS = 60000; // 1 minute in advance +const TOKEN_REFRESH_THRESHOLD_MS = 60000; class OAuthInterceptor { /** @@ -39,13 +39,11 @@ class OAuthInterceptor { * @returns {Promise} Access token */ async getAccessToken() { - // If already refreshing, wait for that promise if (this.tokenRefreshPromise) { this.logDebug('Waiting for ongoing token refresh'); return this.tokenRefreshPromise; } - // Check if current token is valid and not expiring soon if (this.accessToken && this.tokenExpiresAt) { const now = Date.now(); const timeUntilExpiry = this.tokenExpiresAt - now; @@ -58,7 +56,6 @@ class OAuthInterceptor { this.logDebug('Token expiring soon, refreshing'); } - // Start token refresh this.tokenRefreshPromise = this.refreshToken(); try { @@ -108,7 +105,6 @@ class OAuthInterceptor { this.accessToken = accessToken; - // Calculate token expiration time if (expiresIn) { this.tokenExpiresAt = Date.now() + expiresIn * 1000; this.logDebug(`Token obtained, expires in ${expiresIn} seconds`); @@ -143,7 +139,6 @@ class OAuthInterceptor { this.logDebug(`Request to ${config.url} with OAuth token`); return config; } catch (error) { - // Log error but don't throw to not break the request flow console.error('[OAuth] Failed to obtain access token, request may fail:', error.message); return config; } diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 7c83a268..857f09f3 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -40,7 +40,6 @@ class RPClient { this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); - // Determine authentication method: OAuth takes precedence over API key const headers = { 'User-Agent': 'NodeJS', 'Content-Type': 'application/json; charset=UTF-8', From 8d047c667e5d13cc44c5ff5aad50c5dfb8f9519e Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Mon, 20 Oct 2025 14:38:01 +0200 Subject: [PATCH 3/7] EPMRPP-108464 || Names adjustments --- lib/commons/config.js | 13 ++----------- lib/oauth.js | 5 +++-- lib/report-portal-client.js | 4 ++-- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/lib/commons/config.js b/lib/commons/config.js index 42ace0e2..1fdd042a 100644 --- a/lib/commons/config.js +++ b/lib/commons/config.js @@ -32,23 +32,14 @@ const getApiKey = ({ apiKey, token }) => { }; const getOAuthConfig = (options) => { - // Support both nested oauth object and flat structure - let oauthParams = options.oauth || {}; - - // If oauth object doesn't exist, check for flat structure (backward compatibility) - if (!options.oauth) { - const { tokenEndpoint, username, password, clientId, clientSecret, scope } = options; - oauthParams = { tokenEndpoint, username, password, clientId, clientSecret, scope }; - } + const oauthParams = options.oauth || {}; const { tokenEndpoint, username, password, clientId, clientSecret, scope } = oauthParams; - // Check if any OAuth params are provided if (!tokenEndpoint && !username && !password && !clientId) { return null; } - // Validate required OAuth parameters if (!tokenEndpoint) { throw new ReportPortalRequiredOptionError('oauth.tokenEndpoint'); } @@ -110,7 +101,7 @@ const getClientConfig = (options) => { calculatedOptions = { apiKey, - oauthConfig, + oauth: oauthConfig, project, endpoint, launch: options.launch, diff --git a/lib/oauth.js b/lib/oauth.js index 3d21a424..64ac2303 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -117,7 +117,9 @@ class OAuthInterceptor { return this.accessToken; } catch (error) { const errorMessage = error.response - ? `OAuth token request failed: ${error.response.status} - ${JSON.stringify(error.response.data)}` + ? `OAuth token request failed: ${error.response.status} - ${JSON.stringify( + error.response.data, + )}` : `OAuth token request failed: ${error.message}`; console.error(`[OAuth] ${errorMessage}`); @@ -149,4 +151,3 @@ class OAuthInterceptor { } module.exports = OAuthInterceptor; - diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 857f09f3..9869cf41 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -48,7 +48,7 @@ class RPClient { // Only set API key header if OAuth is not configured // OAuth interceptor will handle Authorization header when configured - if (!this.config.oauthConfig && this.apiKey) { + if (!this.config.oauth && this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}`; } @@ -58,7 +58,7 @@ class RPClient { baseURL: this.baseURL, headers: this.headers, restClientConfig: this.config.restClientConfig, - oauthConfig: this.config.oauthConfig, + oauthConfig: this.config.oauth, debug: this.debug, }); this.statistics = new Statistics(EVENT_NAME, agentParams); From 875c830f6639985102ca70c4df647f36259166dd Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Mon, 20 Oct 2025 16:55:24 +0200 Subject: [PATCH 4/7] EPMRPP-108464 || Use refresh_token grant type for token refreshes --- lib/oauth.js | 53 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/oauth.js b/lib/oauth.js index 64ac2303..88531b61 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -1,6 +1,10 @@ const axios = require('axios'); 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 { /** @@ -24,8 +28,9 @@ class OAuthInterceptor { this.debug = config.debug || false; this.accessToken = null; + this.refreshToken = null; this.tokenExpiresAt = null; - this.tokenRefreshPromise = null; + this.tokenRenewPromise = null; } logDebug(message, data = '') { @@ -39,9 +44,9 @@ class OAuthInterceptor { * @returns {Promise} Access token */ async getAccessToken() { - if (this.tokenRefreshPromise) { + if (this.tokenRenewPromise) { this.logDebug('Waiting for ongoing token refresh'); - return this.tokenRefreshPromise; + return this.tokenRenewPromise; } if (this.accessToken && this.tokenExpiresAt) { @@ -56,37 +61,42 @@ class OAuthInterceptor { this.logDebug('Token expiring soon, refreshing'); } - this.tokenRefreshPromise = this.refreshToken(); + this.tokenRenewPromise = this.renewToken(); try { - const token = await this.tokenRefreshPromise; + const token = await this.tokenRenewPromise; return token; } catch (error) { this.logDebug('Error during token refresh', error); throw error; } finally { - this.tokenRefreshPromise = null; + this.tokenRenewPromise = null; } } /** - * Refreshes the access token using password grant + * Refreshes the access token using password grant or refresh token grant * @returns {Promise} Access token */ - async refreshToken() { + async renewToken() { try { - this.logDebug('Requesting new access token'); - const params = new URLSearchParams(); - params.append('grant_type', 'password'); - params.append('username', this.username); - params.append('password', this.password); params.append('client_id', this.clientId); + if (this.refreshToken) { + this.logDebug('Requesting new access token using refresh_token'); + params.append('grant_type', GRANT_TYPE_REFRESH_TOKEN); + params.append('refresh_token', this.refreshToken); + } else { + this.logDebug('Requesting initial access token using username and password'); + params.append('grant_type', GRANT_TYPE_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); } @@ -97,7 +107,11 @@ class OAuthInterceptor { }, }); - const { access_token: accessToken, expires_in: expiresIn } = response.data; + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + } = response.data; if (!accessToken) { throw new Error('No access token received from OAuth server'); @@ -105,12 +119,17 @@ class OAuthInterceptor { this.accessToken = accessToken; + if (refreshToken) { + this.refreshToken = refreshToken; + this.logDebug('Refresh token stored for future use'); + } + if (expiresIn) { - this.tokenExpiresAt = Date.now() + expiresIn * 1000; + this.tokenExpiresAt = Date.now() + expiresIn * SECOND_IN_MS; this.logDebug(`Token obtained, expires in ${expiresIn} seconds`); } else { // If no expiration provided, assume token is valid for 1 hour - this.tokenExpiresAt = Date.now() + 3600000; + this.tokenExpiresAt = Date.now() + DEFAULT_TOKEN_EXPIRATION_MS; this.logDebug('Token obtained, no expiration provided, assuming 1 hour'); } From e24fa2ca9def39a9926edd64be38a65501ae07b3 Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Mon, 20 Oct 2025 17:17:32 +0200 Subject: [PATCH 5/7] EPMRPP-108464 || Add tests --- __tests__/oauth.spec.js | 218 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 __tests__/oauth.spec.js diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js new file mode 100644 index 00000000..866db3d1 --- /dev/null +++ b/__tests__/oauth.spec.js @@ -0,0 +1,218 @@ +const axios = require('axios'); +const OAuthInterceptor = require('../lib/oauth'); + +jest.mock('axios', () => ({ + post: jest.fn(), +})); + +describe('OAuthInterceptor', () => { + const baseConfig = { + tokenEndpoint: 'https://auth.example.com/oauth/token', + username: 'user', + password: 'password', + clientId: 'client-id', + clientSecret: 'client-secret', + scope: 'basic', + }; + const TOKEN_REFRESH_THRESHOLD_MS = 60000; + const DEFAULT_TOKEN_EXPIRATION_MS = 3600000; + + beforeEach(() => { + axios.post.mockReset(); + }); + + it('requests an access token using password grant on first call', async () => { + const baseTime = 1700000000000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + axios.post.mockResolvedValue({ + data: { + access_token: 'token-123', + refresh_token: 'refresh-123', + expires_in: 120, + }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('token-123'); + expect(axios.post).toHaveBeenCalledTimes(1); + const [url, params, config] = axios.post.mock.calls[0]; + + expect(url).toBe(baseConfig.tokenEndpoint); + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('password'); + expect(params.get('username')).toBe(baseConfig.username); + expect(params.get('password')).toBe(baseConfig.password); + expect(params.get('client_id')).toBe(baseConfig.clientId); + expect(params.get('client_secret')).toBe(baseConfig.clientSecret); + expect(params.get('scope')).toBe(baseConfig.scope); + expect(config).toEqual({ + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + expect(oauthInterceptor.refreshToken).toBe('refresh-123'); + expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + 120000); + + nowSpy.mockRestore(); + }); + + it('returns cached token when it is not expiring soon', async () => { + const baseTime = 1700000100000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + oauthInterceptor.accessToken = 'cached-token'; + oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 5000; + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('cached-token'); + expect(axios.post).not.toHaveBeenCalled(); + + nowSpy.mockRestore(); + }); + + it('refreshes token using stored refresh token when it is close to expiring', async () => { + const baseTime = 1700000200000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + oauthInterceptor.accessToken = 'stale-token'; + oauthInterceptor.refreshToken = 'stored-refresh'; + oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS - 1000; + axios.post.mockResolvedValue({ + data: { + access_token: 'fresh-token', + refresh_token: 'fresh-refresh', + }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('fresh-token'); + expect(axios.post).toHaveBeenCalledTimes(1); + const [, params] = axios.post.mock.calls[0]; + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('refresh_token')).toBe('stored-refresh'); + expect(oauthInterceptor.refreshToken).toBe('fresh-refresh'); + expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + DEFAULT_TOKEN_EXPIRATION_MS); + + nowSpy.mockRestore(); + }); + + it('waits for ongoing token renewal and reuses the resolved token', async () => { + const baseTime = 1700000300000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + + let resolveRequest; + const tokenResponsePromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + axios.post.mockReturnValue(tokenResponsePromise); + + const firstCall = oauthInterceptor.getAccessToken(); + const secondCall = oauthInterceptor.getAccessToken(); + + expect(axios.post).toHaveBeenCalledTimes(1); + resolveRequest({ + data: { + access_token: 'shared-token', + refresh_token: 'shared-refresh', + expires_in: 1800, + }, + }); + + const [token1, token2] = await Promise.all([firstCall, secondCall]); + + expect(token1).toBe('shared-token'); + expect(token2).toBe('shared-token'); + expect(oauthInterceptor.tokenRenewPromise).toBeNull(); + + nowSpy.mockRestore(); + }); + + it('logs an error and throws descriptive message when token request fails', async () => { + const oauthInterceptor = new OAuthInterceptor(baseConfig); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + axios.post.mockRejectedValue({ + response: { + status: 400, + data: { error: 'invalid_grant' }, + }, + }); + + await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( + 'OAuth token request failed: 400 - {"error":"invalid_grant"}', + ); + expect(consoleSpy).toHaveBeenCalledWith( + '[OAuth] OAuth token request failed: 400 - {"error":"invalid_grant"}', + ); + + consoleSpy.mockRestore(); + }); + + it('logs debug messages only when debug mode is enabled', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const oauthInterceptor = new OAuthInterceptor({ ...baseConfig, debug: true }); + oauthInterceptor.logDebug('message', { foo: 'bar' }); + + expect(consoleSpy).toHaveBeenCalledWith('[OAuth] message', { foo: 'bar' }); + + consoleSpy.mockRestore(); + }); + + it('injects Authorization header through attached request interceptor', async () => { + const baseTime = 1700000400000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + oauthInterceptor.accessToken = 'cached-token'; + oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 1000; + let requestHandler; + const axiosInstance = { + interceptors: { + request: { + use: jest.fn((fulfilled) => { + requestHandler = fulfilled; + }), + }, + }, + }; + + oauthInterceptor.attach(axiosInstance); + const requestConfig = await requestHandler({ headers: {}, url: '/launch' }); + + expect(requestConfig.headers.Authorization).toBe('Bearer cached-token'); + expect(axios.post).not.toHaveBeenCalled(); + + nowSpy.mockRestore(); + }); + + it('keeps request going when Authorization injection fails', async () => { + const oauthInterceptor = new OAuthInterceptor(baseConfig); + const error = new Error('refresh failed'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const tokenSpy = jest.spyOn(oauthInterceptor, 'getAccessToken').mockRejectedValue(error); + let requestHandler; + const axiosInstance = { + interceptors: { + request: { + use: jest.fn((fulfilled) => { + requestHandler = fulfilled; + }), + }, + }, + }; + + oauthInterceptor.attach(axiosInstance); + const requestConfig = await requestHandler({ headers: {}, url: '/launch' }); + + expect(requestConfig.headers.Authorization).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + '[OAuth] Failed to obtain access token, request may fail:', + 'refresh failed', + ); + expect(tokenSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + tokenSpy.mockRestore(); + }); +}); From 745c3692c3a2db743f59cfe57aaa88700d54fcd7 Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Mon, 20 Oct 2025 17:24:52 +0200 Subject: [PATCH 6/7] EPMRPP-108464 || Fix linter errors --- __tests__/oauth.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index 866db3d1..648931cb 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.js @@ -132,7 +132,7 @@ describe('OAuthInterceptor', () => { it('logs an error and throws descriptive message when token request fails', async () => { const oauthInterceptor = new OAuthInterceptor(baseConfig); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); axios.post.mockRejectedValue({ response: { status: 400, @@ -151,7 +151,7 @@ describe('OAuthInterceptor', () => { }); it('logs debug messages only when debug mode is enabled', () => { - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); const oauthInterceptor = new OAuthInterceptor({ ...baseConfig, debug: true }); oauthInterceptor.logDebug('message', { foo: 'bar' }); @@ -189,7 +189,7 @@ describe('OAuthInterceptor', () => { it('keeps request going when Authorization injection fails', async () => { const oauthInterceptor = new OAuthInterceptor(baseConfig); const error = new Error('refresh failed'); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const tokenSpy = jest.spyOn(oauthInterceptor, 'getAccessToken').mockRejectedValue(error); let requestHandler; const axiosInstance = { From c5bf13952ceaa5aec8f2856d7a44b210fcd94833 Mon Sep 17 00:00:00 2001 From: Ilya_Hancharyk Date: Mon, 20 Oct 2025 18:22:29 +0200 Subject: [PATCH 7/7] EPMRPP-108464 || Cover case when delay between requests bigger than the token lifetime --- __tests__/oauth.spec.js | 94 ++++++++++++++++++++++++++++ lib/oauth.js | 135 +++++++++++++++++++++++++--------------- 2 files changed, 178 insertions(+), 51 deletions(-) diff --git a/__tests__/oauth.spec.js b/__tests__/oauth.spec.js index 648931cb..506e7fa6 100644 --- a/__tests__/oauth.spec.js +++ b/__tests__/oauth.spec.js @@ -215,4 +215,98 @@ describe('OAuthInterceptor', () => { consoleSpy.mockRestore(); tokenSpy.mockRestore(); }); + + it('falls back to password grant when refresh token is expired or invalid', async () => { + const baseTime = 1700000500000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + oauthInterceptor.accessToken = 'old-token'; + oauthInterceptor.refreshToken = 'expired-refresh-token'; + oauthInterceptor.tokenExpiresAt = baseTime - 1000; // Token already expired + + // First call (refresh token) fails + axios.post + .mockRejectedValueOnce({ + response: { + status: 400, + data: { error: 'invalid_grant', error_description: 'refresh token expired' }, + }, + }) + // Second call (password grant fallback) succeeds + .mockResolvedValueOnce({ + data: { + access_token: 'new-token-from-password', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }, + }); + + const token = await oauthInterceptor.getAccessToken(); + + expect(token).toBe('new-token-from-password'); + expect(axios.post).toHaveBeenCalledTimes(2); + + // First call should be refresh_token grant + const [, firstParams] = axios.post.mock.calls[0]; + expect(firstParams.get('grant_type')).toBe('refresh_token'); + expect(firstParams.get('refresh_token')).toBe('expired-refresh-token'); + + // Second call should be password grant + const [, secondParams] = axios.post.mock.calls[1]; + expect(secondParams.get('grant_type')).toBe('password'); + expect(secondParams.get('username')).toBe(baseConfig.username); + expect(secondParams.get('password')).toBe(baseConfig.password); + + // Verify new tokens are stored + expect(oauthInterceptor.accessToken).toBe('new-token-from-password'); + expect(oauthInterceptor.refreshToken).toBe('new-refresh-token'); + + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[OAuth] Refresh token expired or invalid, re-authenticating with password grant', + ); + + nowSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('throws error when both refresh token and password grant fail', async () => { + const baseTime = 1700000600000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const oauthInterceptor = new OAuthInterceptor(baseConfig); + oauthInterceptor.refreshToken = 'expired-refresh-token'; + oauthInterceptor.tokenExpiresAt = baseTime - 1000; + + // Both calls fail + axios.post + .mockRejectedValueOnce({ + response: { + status: 400, + data: { error: 'invalid_grant' }, + }, + }) + .mockRejectedValueOnce({ + response: { + status: 401, + data: { error: 'invalid_credentials' }, + }, + }); + + await expect(oauthInterceptor.getAccessToken()).rejects.toThrow( + 'OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}', + ); + + expect(axios.post).toHaveBeenCalledTimes(2); + expect(consoleWarnSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[OAuth] OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}', + ); + + nowSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); }); diff --git a/lib/oauth.js b/lib/oauth.js index 88531b61..180d9393 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -80,61 +80,33 @@ class OAuthInterceptor { */ async renewToken() { try { - const params = new URLSearchParams(); - params.append('client_id', this.clientId); - + 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('Requesting new access token using refresh_token'); - params.append('grant_type', GRANT_TYPE_REFRESH_TOKEN); - params.append('refresh_token', this.refreshToken); - } else { - this.logDebug('Requesting initial access token using username and password'); - params.append('grant_type', GRANT_TYPE_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); - } - - const response = await axios.post(this.tokenEndpoint, params, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - 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; + 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 - 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 { - // If no expiration provided, assume token is valid for 1 hour - this.tokenExpiresAt = Date.now() + DEFAULT_TOKEN_EXPIRATION_MS; - this.logDebug('Token obtained, no expiration provided, assuming 1 hour'); + 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); + } } - return this.accessToken; - } catch (error) { + // No fallback available, rethrow original error const errorMessage = error.response ? `OAuth token request failed: ${error.response.status} - ${JSON.stringify( error.response.data, @@ -146,6 +118,67 @@ class OAuthInterceptor { } } + /** + * Requests a token using the specified grant type + * @param {string} grantType - Either 'password' or 'refresh_token' + * @returns {Promise} 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); + } + + const response = await axios.post(this.tokenEndpoint, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + 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