Skip to content

Commit 11c987d

Browse files
authored
EPMRPP-108464 || OAuth password grant type support (#233)
* EPMRPP-108464 || OAuth password grant type support * EPMRPP-108464 || Update config structure. Remove redundant comments * EPMRPP-108464 || Names adjustments * EPMRPP-108464 || Use refresh_token grant type for token refreshes * EPMRPP-108464 || Add tests * EPMRPP-108464 || Fix linter errors * EPMRPP-108464 || Cover case when delay between requests bigger than the token lifetime
1 parent 3413643 commit 11c987d

7 files changed

Lines changed: 710 additions & 7 deletions

File tree

README.md

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ The latest version is available on npm:
2828
npm install @reportportal/client-javascript
2929
```
3030

31-
## Usage example
31+
## Usage examples
32+
33+
### Using API Key Authentication
3234

3335
```javascript
3436
const RPClient = require('@reportportal/client-javascript');
@@ -48,13 +50,69 @@ rpClient.checkConnect().then(() => {
4850
});
4951
```
5052

53+
### Using OAuth 2.0 Password Grant
54+
55+
```javascript
56+
const RPClient = require('@reportportal/client-javascript');
57+
58+
const rpClient = new RPClient({
59+
endpoint: 'http://your-instance.com:8080/api/v1',
60+
launch: 'LAUNCH_NAME',
61+
project: 'PROJECT_NAME',
62+
oauth: {
63+
tokenEndpoint: 'https://your-oauth-server.com/oauth/token',
64+
username: 'your-username',
65+
password: 'your-password',
66+
clientId: 'your-client-id',
67+
clientSecret: 'your-client-secret', // optional
68+
scope: 'reportportal', // optional
69+
}
70+
});
71+
72+
rpClient.checkConnect().then(() => {
73+
console.log('You have successfully connected to the server.');
74+
}, (error) => {
75+
console.log('Error connection to server');
76+
console.dir(error);
77+
});
78+
```
79+
80+
**Note:** The OAuth interceptor automatically handles token refresh when the token is about to expire (1 minute before expiration).
81+
5182
## Configuration
5283

5384
When creating a client instance, you need to specify the following options:
5485

86+
### Authentication Options
87+
88+
The client supports two authentication methods:
89+
1. **API Key Authentication** (default)
90+
2. **OAuth 2.0 Password Grant** (recommended for enhanced security)
91+
92+
**Note:** If both authentication methods are provided, OAuth 2.0 will be used.
93+
94+
| Option | Necessity | Default | Description |
95+
|-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
96+
| 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. |
97+
| oauth | Optional | | OAuth 2.0 configuration object. When provided, OAuth authentication will be used instead of API key. See OAuth Configuration below. |
98+
99+
#### OAuth Configuration
100+
101+
The `oauth` object supports the following properties:
102+
103+
| Property | Necessity | Default | Description |
104+
|-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
105+
| tokenEndpoint | Required | | OAuth 2.0 token endpoint URL for password grant flow. |
106+
| username | Required | | Username for OAuth 2.0 password grant. |
107+
| password | Required | | Password for OAuth 2.0 password grant. |
108+
| clientId | Required | | OAuth 2.0 client ID. |
109+
| clientSecret | Optional | | OAuth 2.0 client secret (optional, depending on your OAuth server configuration). |
110+
| scope | Optional | | OAuth 2.0 scope (optional, space-separated list of scopes). |
111+
112+
### General Options
113+
55114
| Option | Necessity | Default | Description |
56115
|-----------------------|------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
57-
| apiKey | Required | | User's reportportal token from which you want to send requests. It can be found on the profile page of this user. |
58116
| 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'. |
59117
| launch | Required | | Name of the launch at creation. |
60118
| project | Required | | The name of the project in which the launches will be created. |

__tests__/oauth.spec.js

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
const axios = require('axios');
2+
const OAuthInterceptor = require('../lib/oauth');
3+
4+
jest.mock('axios', () => ({
5+
post: jest.fn(),
6+
}));
7+
8+
describe('OAuthInterceptor', () => {
9+
const baseConfig = {
10+
tokenEndpoint: 'https://auth.example.com/oauth/token',
11+
username: 'user',
12+
password: 'password',
13+
clientId: 'client-id',
14+
clientSecret: 'client-secret',
15+
scope: 'basic',
16+
};
17+
const TOKEN_REFRESH_THRESHOLD_MS = 60000;
18+
const DEFAULT_TOKEN_EXPIRATION_MS = 3600000;
19+
20+
beforeEach(() => {
21+
axios.post.mockReset();
22+
});
23+
24+
it('requests an access token using password grant on first call', async () => {
25+
const baseTime = 1700000000000;
26+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
27+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
28+
axios.post.mockResolvedValue({
29+
data: {
30+
access_token: 'token-123',
31+
refresh_token: 'refresh-123',
32+
expires_in: 120,
33+
},
34+
});
35+
36+
const token = await oauthInterceptor.getAccessToken();
37+
38+
expect(token).toBe('token-123');
39+
expect(axios.post).toHaveBeenCalledTimes(1);
40+
const [url, params, config] = axios.post.mock.calls[0];
41+
42+
expect(url).toBe(baseConfig.tokenEndpoint);
43+
expect(params).toBeInstanceOf(URLSearchParams);
44+
expect(params.get('grant_type')).toBe('password');
45+
expect(params.get('username')).toBe(baseConfig.username);
46+
expect(params.get('password')).toBe(baseConfig.password);
47+
expect(params.get('client_id')).toBe(baseConfig.clientId);
48+
expect(params.get('client_secret')).toBe(baseConfig.clientSecret);
49+
expect(params.get('scope')).toBe(baseConfig.scope);
50+
expect(config).toEqual({
51+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
52+
});
53+
expect(oauthInterceptor.refreshToken).toBe('refresh-123');
54+
expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + 120000);
55+
56+
nowSpy.mockRestore();
57+
});
58+
59+
it('returns cached token when it is not expiring soon', async () => {
60+
const baseTime = 1700000100000;
61+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
62+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
63+
oauthInterceptor.accessToken = 'cached-token';
64+
oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 5000;
65+
66+
const token = await oauthInterceptor.getAccessToken();
67+
68+
expect(token).toBe('cached-token');
69+
expect(axios.post).not.toHaveBeenCalled();
70+
71+
nowSpy.mockRestore();
72+
});
73+
74+
it('refreshes token using stored refresh token when it is close to expiring', async () => {
75+
const baseTime = 1700000200000;
76+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
77+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
78+
oauthInterceptor.accessToken = 'stale-token';
79+
oauthInterceptor.refreshToken = 'stored-refresh';
80+
oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS - 1000;
81+
axios.post.mockResolvedValue({
82+
data: {
83+
access_token: 'fresh-token',
84+
refresh_token: 'fresh-refresh',
85+
},
86+
});
87+
88+
const token = await oauthInterceptor.getAccessToken();
89+
90+
expect(token).toBe('fresh-token');
91+
expect(axios.post).toHaveBeenCalledTimes(1);
92+
const [, params] = axios.post.mock.calls[0];
93+
expect(params.get('grant_type')).toBe('refresh_token');
94+
expect(params.get('refresh_token')).toBe('stored-refresh');
95+
expect(oauthInterceptor.refreshToken).toBe('fresh-refresh');
96+
expect(oauthInterceptor.tokenExpiresAt).toBe(baseTime + DEFAULT_TOKEN_EXPIRATION_MS);
97+
98+
nowSpy.mockRestore();
99+
});
100+
101+
it('waits for ongoing token renewal and reuses the resolved token', async () => {
102+
const baseTime = 1700000300000;
103+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
104+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
105+
106+
let resolveRequest;
107+
const tokenResponsePromise = new Promise((resolve) => {
108+
resolveRequest = resolve;
109+
});
110+
axios.post.mockReturnValue(tokenResponsePromise);
111+
112+
const firstCall = oauthInterceptor.getAccessToken();
113+
const secondCall = oauthInterceptor.getAccessToken();
114+
115+
expect(axios.post).toHaveBeenCalledTimes(1);
116+
resolveRequest({
117+
data: {
118+
access_token: 'shared-token',
119+
refresh_token: 'shared-refresh',
120+
expires_in: 1800,
121+
},
122+
});
123+
124+
const [token1, token2] = await Promise.all([firstCall, secondCall]);
125+
126+
expect(token1).toBe('shared-token');
127+
expect(token2).toBe('shared-token');
128+
expect(oauthInterceptor.tokenRenewPromise).toBeNull();
129+
130+
nowSpy.mockRestore();
131+
});
132+
133+
it('logs an error and throws descriptive message when token request fails', async () => {
134+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
135+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
136+
axios.post.mockRejectedValue({
137+
response: {
138+
status: 400,
139+
data: { error: 'invalid_grant' },
140+
},
141+
});
142+
143+
await expect(oauthInterceptor.getAccessToken()).rejects.toThrow(
144+
'OAuth token request failed: 400 - {"error":"invalid_grant"}',
145+
);
146+
expect(consoleSpy).toHaveBeenCalledWith(
147+
'[OAuth] OAuth token request failed: 400 - {"error":"invalid_grant"}',
148+
);
149+
150+
consoleSpy.mockRestore();
151+
});
152+
153+
it('logs debug messages only when debug mode is enabled', () => {
154+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
155+
const oauthInterceptor = new OAuthInterceptor({ ...baseConfig, debug: true });
156+
oauthInterceptor.logDebug('message', { foo: 'bar' });
157+
158+
expect(consoleSpy).toHaveBeenCalledWith('[OAuth] message', { foo: 'bar' });
159+
160+
consoleSpy.mockRestore();
161+
});
162+
163+
it('injects Authorization header through attached request interceptor', async () => {
164+
const baseTime = 1700000400000;
165+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
166+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
167+
oauthInterceptor.accessToken = 'cached-token';
168+
oauthInterceptor.tokenExpiresAt = baseTime + TOKEN_REFRESH_THRESHOLD_MS + 1000;
169+
let requestHandler;
170+
const axiosInstance = {
171+
interceptors: {
172+
request: {
173+
use: jest.fn((fulfilled) => {
174+
requestHandler = fulfilled;
175+
}),
176+
},
177+
},
178+
};
179+
180+
oauthInterceptor.attach(axiosInstance);
181+
const requestConfig = await requestHandler({ headers: {}, url: '/launch' });
182+
183+
expect(requestConfig.headers.Authorization).toBe('Bearer cached-token');
184+
expect(axios.post).not.toHaveBeenCalled();
185+
186+
nowSpy.mockRestore();
187+
});
188+
189+
it('keeps request going when Authorization injection fails', async () => {
190+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
191+
const error = new Error('refresh failed');
192+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
193+
const tokenSpy = jest.spyOn(oauthInterceptor, 'getAccessToken').mockRejectedValue(error);
194+
let requestHandler;
195+
const axiosInstance = {
196+
interceptors: {
197+
request: {
198+
use: jest.fn((fulfilled) => {
199+
requestHandler = fulfilled;
200+
}),
201+
},
202+
},
203+
};
204+
205+
oauthInterceptor.attach(axiosInstance);
206+
const requestConfig = await requestHandler({ headers: {}, url: '/launch' });
207+
208+
expect(requestConfig.headers.Authorization).toBeUndefined();
209+
expect(consoleSpy).toHaveBeenCalledWith(
210+
'[OAuth] Failed to obtain access token, request may fail:',
211+
'refresh failed',
212+
);
213+
expect(tokenSpy).toHaveBeenCalled();
214+
215+
consoleSpy.mockRestore();
216+
tokenSpy.mockRestore();
217+
});
218+
219+
it('falls back to password grant when refresh token is expired or invalid', async () => {
220+
const baseTime = 1700000500000;
221+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
222+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
223+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
224+
oauthInterceptor.accessToken = 'old-token';
225+
oauthInterceptor.refreshToken = 'expired-refresh-token';
226+
oauthInterceptor.tokenExpiresAt = baseTime - 1000; // Token already expired
227+
228+
// First call (refresh token) fails
229+
axios.post
230+
.mockRejectedValueOnce({
231+
response: {
232+
status: 400,
233+
data: { error: 'invalid_grant', error_description: 'refresh token expired' },
234+
},
235+
})
236+
// Second call (password grant fallback) succeeds
237+
.mockResolvedValueOnce({
238+
data: {
239+
access_token: 'new-token-from-password',
240+
refresh_token: 'new-refresh-token',
241+
expires_in: 3600,
242+
},
243+
});
244+
245+
const token = await oauthInterceptor.getAccessToken();
246+
247+
expect(token).toBe('new-token-from-password');
248+
expect(axios.post).toHaveBeenCalledTimes(2);
249+
250+
// First call should be refresh_token grant
251+
const [, firstParams] = axios.post.mock.calls[0];
252+
expect(firstParams.get('grant_type')).toBe('refresh_token');
253+
expect(firstParams.get('refresh_token')).toBe('expired-refresh-token');
254+
255+
// Second call should be password grant
256+
const [, secondParams] = axios.post.mock.calls[1];
257+
expect(secondParams.get('grant_type')).toBe('password');
258+
expect(secondParams.get('username')).toBe(baseConfig.username);
259+
expect(secondParams.get('password')).toBe(baseConfig.password);
260+
261+
// Verify new tokens are stored
262+
expect(oauthInterceptor.accessToken).toBe('new-token-from-password');
263+
expect(oauthInterceptor.refreshToken).toBe('new-refresh-token');
264+
265+
// Verify warning was logged
266+
expect(consoleWarnSpy).toHaveBeenCalledWith(
267+
'[OAuth] Refresh token expired or invalid, re-authenticating with password grant',
268+
);
269+
270+
nowSpy.mockRestore();
271+
consoleWarnSpy.mockRestore();
272+
});
273+
274+
it('throws error when both refresh token and password grant fail', async () => {
275+
const baseTime = 1700000600000;
276+
const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => baseTime);
277+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
278+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
279+
const oauthInterceptor = new OAuthInterceptor(baseConfig);
280+
oauthInterceptor.refreshToken = 'expired-refresh-token';
281+
oauthInterceptor.tokenExpiresAt = baseTime - 1000;
282+
283+
// Both calls fail
284+
axios.post
285+
.mockRejectedValueOnce({
286+
response: {
287+
status: 400,
288+
data: { error: 'invalid_grant' },
289+
},
290+
})
291+
.mockRejectedValueOnce({
292+
response: {
293+
status: 401,
294+
data: { error: 'invalid_credentials' },
295+
},
296+
});
297+
298+
await expect(oauthInterceptor.getAccessToken()).rejects.toThrow(
299+
'OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}',
300+
);
301+
302+
expect(axios.post).toHaveBeenCalledTimes(2);
303+
expect(consoleWarnSpy).toHaveBeenCalled();
304+
expect(consoleErrorSpy).toHaveBeenCalledWith(
305+
'[OAuth] OAuth password grant fallback failed: 401 - {"error":"invalid_credentials"}',
306+
);
307+
308+
nowSpy.mockRestore();
309+
consoleErrorSpy.mockRestore();
310+
consoleWarnSpy.mockRestore();
311+
});
312+
});

0 commit comments

Comments
 (0)