diff --git a/README.md b/README.md index 85c678b5..dc468156 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,69 @@ rpClient.checkConnect().then(() => { }); ``` +### Using OAuth 2.0 Password Grant + +```javascript +const RPClient = require('@reportportal/client-javascript'); + +const rpClient = new RPClient({ + endpoint: 'http://your-instance.com:8080/api/v1', + launch: 'LAUNCH_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(() => { + 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. | +| 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). | + +### 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/__tests__/oauth.spec.js b/__tests__/oauth.spec.js new file mode 100644 index 00000000..506e7fa6 --- /dev/null +++ b/__tests__/oauth.spec.js @@ -0,0 +1,312 @@ +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(); + }); + + 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/index.d.ts b/index.d.ts index fd974fe4..314059a2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,38 @@ 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. * - * @example + * @example API Key Authentication * ```typescript * const rp = new ReportPortalClient({ * endpoint: 'https://your.reportportal.server/api/v1', @@ -10,9 +40,25 @@ 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', + * 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 + * } + * }); + * ``` */ export interface ReportPortalConfig { - apiKey: string; + apiKey?: string; endpoint: string; launch: string; project: string; @@ -23,6 +69,10 @@ declare module '@reportportal/client-javascript' { launchUuidPrintOutput?: string; restClientConfig?: Record; token?: string; + /** + * OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. + */ + oauth?: OAuthConfig; } /** diff --git a/lib/commons/config.js b/lib/commons/config.js index a09b468f..1fdd042a 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 oauthParams = options.oauth || {}; + + const { tokenEndpoint, username, password, clientId, clientSecret, scope } = oauthParams; + + if (!tokenEndpoint && !username && !password && !clientId) { + return null; + } + + if (!tokenEndpoint) { + throw new ReportPortalRequiredOptionError('oauth.tokenEndpoint'); + } + if (!username) { + throw new ReportPortalRequiredOptionError('oauth.username'); + } + if (!password) { + throw new ReportPortalRequiredOptionError('oauth.password'); + } + if (!clientId) { + throw new ReportPortalRequiredOptionError('oauth.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, + oauth: 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..180d9393 --- /dev/null +++ b/lib/oauth.js @@ -0,0 +1,205 @@ +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 { + /** + * 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.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} 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} 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} 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 + */ + 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; diff --git a/lib/report-portal-client.js b/lib/report-portal-client.js index 9a2cdb9b..9869cf41 100644 --- a/lib/report-portal-client.js +++ b/lib/report-portal-client.js @@ -39,17 +39,27 @@ class RPClient { this.map = {}; this.baseURL = [this.config.endpoint, this.config.project].join('/'); - this.headers = { + + 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.oauth && 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.oauth, + 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) {