@rockcarver/frodo-lib 0.17.0 → 0.17.2-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -2
- package/cjs/api/TreeApi.test.js.map +1 -1
- package/cjs/api/VariablesApi.test.js.map +1 -1
- package/cjs/ops/AuthenticateOps.js +15 -1
- package/cjs/ops/AuthenticateOps.js.map +1 -1
- package/cjs/ops/AuthenticateOps.test.js.map +1 -1
- package/cjs/ops/ServiceAccountOps.test.js.map +1 -1
- package/cjs/shared/State.js +41 -1
- package/cjs/shared/State.js.map +1 -1
- package/cjs/test/mocks/TreeApi/putTree/FrodoTest.json +0 -2
- package/cjs/utils/AutoSetupPolly.js +43 -30
- package/cjs/utils/AutoSetupPolly.js.map +1 -1
- package/esm/api/TreeApi.test.mjs +240 -137
- package/esm/api/VariablesApi.test.mjs +51 -35
- package/esm/ops/AuthenticateOps.mjs +15 -1
- package/esm/ops/AuthenticateOps.test.mjs +63 -12
- package/esm/ops/ServiceAccountOps.test.mjs +5 -0
- package/esm/shared/State.mjs +41 -1
- package/esm/test/mocks/TreeApi/putTree/FrodoTest.json +0 -2
- package/esm/utils/AutoSetupPolly.mjs +41 -30
- package/package.json +4 -4
- package/types/ops/AuthenticateOps.d.ts.map +1 -1
- package/types/shared/State.d.ts.map +1 -1
- package/types/utils/AutoSetupPolly.d.ts +2 -1
- package/types/utils/AutoSetupPolly.d.ts.map +1 -1
- package/cjs/utils/SetupJest.js +0 -6
- package/cjs/utils/SetupJest.js.map +0 -1
- package/esm/utils/SetupJest.mjs +0 -3
- package/types/utils/SetupJest.d.ts +0 -1
- package/types/utils/SetupJest.d.ts.map +0 -1
|
@@ -1,65 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* To record and update snapshots, you must perform 3 steps in order:
|
|
3
|
+
*
|
|
4
|
+
* 1. Record API responses & update ESM snapshots
|
|
5
|
+
*
|
|
6
|
+
* To record and update ESM snapshots, you must call the test:record
|
|
7
|
+
* script and override all the connection state variables required
|
|
8
|
+
* to connect to the env to record from:
|
|
9
|
+
*
|
|
10
|
+
* FRODO_DEBUG=1 FRODO_HOST=volker-dev npm run test:record VariablesApi
|
|
11
|
+
*
|
|
12
|
+
* The above command assumes that you have a connection profile for
|
|
13
|
+
* 'volker-dev' on your development machine.
|
|
14
|
+
*
|
|
15
|
+
* 2. Update CJS snapshots
|
|
16
|
+
*
|
|
17
|
+
* After recording, the ESM snapshots will already be updated as that happens
|
|
18
|
+
* in one go, but you musty manually update the CJS snapshots by running:
|
|
19
|
+
*
|
|
20
|
+
* FRODO_DEBUG=1 npm run test:update VariablesApi
|
|
21
|
+
*
|
|
22
|
+
* 3. Test your changes
|
|
23
|
+
*
|
|
24
|
+
* If 1 and 2 didn't produce any errors, you are ready to run the tests in
|
|
25
|
+
* replay mode and make sure they all succeed as well:
|
|
26
|
+
*
|
|
27
|
+
* npm run test VariablesApi
|
|
28
|
+
*
|
|
29
|
+
* Note: FRODO_DEBUG=1 is optional and enables debug logging for some output
|
|
30
|
+
* in case things don't function as expected
|
|
31
|
+
*/
|
|
1
32
|
import { VariablesRaw } from '../index';
|
|
2
|
-
import autoSetupPolly from '../utils/AutoSetupPolly';
|
|
3
|
-
|
|
33
|
+
import { autoSetupPolly } from '../utils/AutoSetupPolly';
|
|
34
|
+
autoSetupPolly();
|
|
4
35
|
describe('VariablesApi', () => {
|
|
5
|
-
describe('
|
|
6
|
-
test('
|
|
36
|
+
describe('getVariables()', () => {
|
|
37
|
+
test('0: Method is implemented', async () => {
|
|
7
38
|
expect(VariablesRaw.getVariables).toBeDefined();
|
|
8
39
|
});
|
|
9
|
-
test('
|
|
40
|
+
test('1: Get all variables - success', async () => {
|
|
10
41
|
const response = await VariablesRaw.getVariables();
|
|
11
42
|
expect(response).toMatchSnapshot();
|
|
12
43
|
});
|
|
13
|
-
test('getVariables() 2: Get all variables - error', async () => {
|
|
14
|
-
try {
|
|
15
|
-
await VariablesRaw.getVariables();
|
|
16
|
-
} catch (error) {
|
|
17
|
-
expect(error.response.data).toMatchSnapshot();
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
44
|
});
|
|
21
|
-
describe('
|
|
22
|
-
test('
|
|
45
|
+
describe('getVariable()', () => {
|
|
46
|
+
test('0: Method is implemented', async () => {
|
|
23
47
|
expect(VariablesRaw.getVariable).toBeDefined();
|
|
24
48
|
});
|
|
25
|
-
test('
|
|
49
|
+
test('1: Get existing variable: esv-volkerstestvariable1', async () => {
|
|
26
50
|
const response = await VariablesRaw.getVariable('esv-volkerstestvariable1');
|
|
27
51
|
expect(response).toMatchSnapshot();
|
|
28
52
|
});
|
|
29
|
-
test('
|
|
53
|
+
test('2: Get non-existing variable: esv-does-not-exist', async () => {
|
|
30
54
|
try {
|
|
31
55
|
await VariablesRaw.getVariable('esv-does-not-exist');
|
|
32
56
|
} catch (error) {
|
|
33
|
-
// expect(error.response.status).toBe(404);
|
|
34
57
|
expect(error.response.data).toMatchSnapshot();
|
|
35
58
|
}
|
|
36
59
|
});
|
|
37
60
|
});
|
|
38
|
-
describe('
|
|
39
|
-
test('
|
|
61
|
+
describe('putVariable()', () => {
|
|
62
|
+
test('0: Method is implemented', async () => {
|
|
40
63
|
expect(VariablesRaw.putVariable).toBeDefined();
|
|
41
64
|
});
|
|
42
|
-
test('
|
|
65
|
+
test('1: Create variable: esv-volkerstestvariable2 - success', async () => {
|
|
43
66
|
const response = await VariablesRaw.putVariable('esv-volkerstestvariable2', "Volker's Test Variable Value", "Volker's Test Variable Description");
|
|
44
67
|
expect(response).toMatchSnapshot();
|
|
45
68
|
});
|
|
46
|
-
test('putVariable() 2: Create variable: esv-volkerstestvariable2 - error', async () => {
|
|
47
|
-
try {
|
|
48
|
-
await VariablesRaw.putVariable('esv-volkerstestvariable2', "Volker's Test Variable Value", "Volker's Test Variable Description");
|
|
49
|
-
} catch (error) {
|
|
50
|
-
expect(error.response.data).toMatchSnapshot();
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
69
|
});
|
|
54
|
-
describe('
|
|
55
|
-
test('
|
|
70
|
+
describe('setVariableDescription()', () => {
|
|
71
|
+
test('0: Method is implemented', async () => {
|
|
56
72
|
expect(VariablesRaw.setVariableDescription).toBeDefined();
|
|
57
73
|
});
|
|
58
|
-
test('
|
|
74
|
+
test('1: Set variable description: esv-volkerstestvariable2 - success', async () => {
|
|
59
75
|
const response = await VariablesRaw.setVariableDescription('esv-volkerstestvariable2', "Volker's Updated Test Secret Description");
|
|
60
76
|
expect(response).toMatchSnapshot();
|
|
61
77
|
});
|
|
62
|
-
test('
|
|
78
|
+
test('2: Set variable description: esv-volkerstestvariable3 - error', async () => {
|
|
63
79
|
try {
|
|
64
80
|
await VariablesRaw.setVariableDescription('esv-volkerstestvariable3', "Volker's Updated Test Secret Description");
|
|
65
81
|
} catch (error) {
|
|
@@ -67,15 +83,15 @@ describe('VariablesApi', () => {
|
|
|
67
83
|
}
|
|
68
84
|
});
|
|
69
85
|
});
|
|
70
|
-
describe('
|
|
71
|
-
test('
|
|
86
|
+
describe('deleteVariable()', () => {
|
|
87
|
+
test('0: Method is implemented', async () => {
|
|
72
88
|
expect(VariablesRaw.deleteVariable).toBeDefined();
|
|
73
89
|
});
|
|
74
|
-
test('
|
|
90
|
+
test('1: Delete variable: esv-volkerstestvariable2 - success', async () => {
|
|
75
91
|
const response = await VariablesRaw.deleteVariable('esv-volkerstestvariable2');
|
|
76
92
|
expect(response).toMatchSnapshot();
|
|
77
93
|
});
|
|
78
|
-
test('
|
|
94
|
+
test('2: Delete variable: esv-volkerstestvariable3 - error', async () => {
|
|
79
95
|
try {
|
|
80
96
|
await VariablesRaw.deleteVariable('esv-volkerstestvariable3');
|
|
81
97
|
} catch (error) {
|
|
@@ -87,7 +87,7 @@ function checkAndHandle2FA(payload) {
|
|
|
87
87
|
* @param {string} deploymentType deployment type
|
|
88
88
|
*/
|
|
89
89
|
function determineDefaultRealm(deploymentType) {
|
|
90
|
-
if (state.getRealm() === globalConfig.DEFAULT_REALM_KEY) {
|
|
90
|
+
if (!state.getRealm() || state.getRealm() === globalConfig.DEFAULT_REALM_KEY) {
|
|
91
91
|
state.setRealm(globalConfig.DEPLOYMENT_TYPE_REALM_MAP[deploymentType]);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
@@ -238,6 +238,7 @@ async function getAuthCode(redirectURL, codeChallenge, codeChallengeMethod) {
|
|
|
238
238
|
* @returns {Promise<string | null>} access token or null
|
|
239
239
|
*/
|
|
240
240
|
async function getAccessTokenForUser() {
|
|
241
|
+
debugMessage(`AuthenticateOps.getAccessTokenForUser: start`);
|
|
241
242
|
try {
|
|
242
243
|
const verifier = encodeBase64Url(randomBytes(32));
|
|
243
244
|
const challenge = encodeBase64Url(createHash('sha256').update(verifier).digest());
|
|
@@ -263,6 +264,7 @@ async function getAccessTokenForUser() {
|
|
|
263
264
|
response = await accessToken(bodyFormData);
|
|
264
265
|
}
|
|
265
266
|
if ('access_token' in response.data) {
|
|
267
|
+
debugMessage(`AuthenticateOps.getAccessTokenForUser: end with token`);
|
|
266
268
|
return response.data.access_token;
|
|
267
269
|
}
|
|
268
270
|
printMessage('No access token in response.', 'error');
|
|
@@ -271,6 +273,7 @@ async function getAccessTokenForUser() {
|
|
|
271
273
|
debugMessage(`Error getting access token for user: ${error}`);
|
|
272
274
|
debugMessage((_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : _error$response2.data);
|
|
273
275
|
}
|
|
276
|
+
debugMessage(`AuthenticateOps.getAccessTokenForUser: end without token`);
|
|
274
277
|
return null;
|
|
275
278
|
}
|
|
276
279
|
function createPayload(serviceAccountId) {
|
|
@@ -328,6 +331,7 @@ async function determineDeploymentTypeAndDefaultRealmAndVersion() {
|
|
|
328
331
|
state.setDeploymentType(await determineDeploymentType());
|
|
329
332
|
}
|
|
330
333
|
determineDefaultRealm(state.getDeploymentType());
|
|
334
|
+
debugMessage(`AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: realm=${state.getRealm()}, type=${state.getDeploymentType()}`);
|
|
331
335
|
const versionInfo = (await getServerVersionInfo()).data;
|
|
332
336
|
|
|
333
337
|
// https://github.com/rockcarver/frodo-cli/issues/109
|
|
@@ -351,6 +355,7 @@ async function getLoggedInSubject() {
|
|
|
351
355
|
* @returns {Promise<boolean>} true if tokens were successfully obtained, false otherwise
|
|
352
356
|
*/
|
|
353
357
|
export async function getTokens() {
|
|
358
|
+
debugMessage(`AuthenticateOps.getTokens: start`);
|
|
354
359
|
if (!state.getHost()) {
|
|
355
360
|
printMessage(`No host specified and FRODO_HOST env variable not set!`, 'error');
|
|
356
361
|
return false;
|
|
@@ -393,10 +398,17 @@ export async function getTokens() {
|
|
|
393
398
|
const token = await authenticate(state.getUsername(), state.getPassword());
|
|
394
399
|
if (token) state.setCookieValue(token);
|
|
395
400
|
await determineDeploymentTypeAndDefaultRealmAndVersion();
|
|
401
|
+
debugMessage(`AuthenticateOps.getTokens: before bearer`);
|
|
402
|
+
debugMessage(`AuthenticateOps.getTokens: cookie=${state.getCookieValue()}`);
|
|
403
|
+
debugMessage(`AuthenticateOps.getTokens: bearer=${state.getBearerToken()}`);
|
|
404
|
+
debugMessage(`AuthenticateOps.getTokens: type=${state.getDeploymentType()}`);
|
|
405
|
+
debugMessage(`AuthenticateOps.getTokens: condition=${state.getCookieValue() && !state.getBearerToken() && (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY || state.getDeploymentType() === globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY)}`);
|
|
396
406
|
if (state.getCookieValue() && !state.getBearerToken() && (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY || state.getDeploymentType() === globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY)) {
|
|
407
|
+
debugMessage(`AuthenticateOps.getTokens: in bearer`);
|
|
397
408
|
const accessToken = await getAccessTokenForUser();
|
|
398
409
|
if (accessToken) state.setBearerToken(accessToken);
|
|
399
410
|
}
|
|
411
|
+
debugMessage(`AuthenticateOps.getTokens: after bearer`);
|
|
400
412
|
}
|
|
401
413
|
// incomplete or no credentials
|
|
402
414
|
else {
|
|
@@ -406,6 +418,7 @@ export async function getTokens() {
|
|
|
406
418
|
if (state.getCookieValue() || state.getUseBearerTokenForAmApis() && state.getBearerToken()) {
|
|
407
419
|
// https://github.com/rockcarver/frodo-cli/issues/102
|
|
408
420
|
printMessage(`Connected to ${state.getHost()} [${state.getRealm() ? state.getRealm() : 'root'}] as ${await getLoggedInSubject()}`, 'info');
|
|
421
|
+
debugMessage(`AuthenticateOps.getTokens: end with tokens`);
|
|
409
422
|
return true;
|
|
410
423
|
}
|
|
411
424
|
} catch (error) {
|
|
@@ -421,6 +434,7 @@ export async function getTokens() {
|
|
|
421
434
|
// stack trace
|
|
422
435
|
debugMessage(error.stack || new Error().stack);
|
|
423
436
|
}
|
|
437
|
+
debugMessage(`AuthenticateOps.getTokens: end without tokens`);
|
|
424
438
|
return false;
|
|
425
439
|
}
|
|
426
440
|
//# sourceMappingURL=AuthenticateOps.js.map
|
|
@@ -1,16 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* To record and update snapshots, you must perform 3 steps in order:
|
|
3
|
+
*
|
|
4
|
+
* 1. Record API responses & update ESM snapshots
|
|
5
|
+
*
|
|
6
|
+
* To record and update ESM snapshots, you must call the test:record_noauth
|
|
7
|
+
* script and override all the connection state variables supplied to the
|
|
8
|
+
* getTokens() function by the test to connect to the env to record from:
|
|
9
|
+
*
|
|
10
|
+
* FRODO_DEBUG=1 FRODO_HOST=https://openam-volker-dev.forgeblocks.com/am \
|
|
11
|
+
* FRODO_USERNAME=volker.scheuber@forgerock.com FRODO_PASSWORD='S3cr3!S@uc3' \
|
|
12
|
+
* npm run test:record_noauth AuthenticateOps
|
|
13
|
+
*
|
|
14
|
+
* 2. Update CJS snapshots
|
|
15
|
+
*
|
|
16
|
+
* After recording, the ESM snapshots will already be updated as that happens
|
|
17
|
+
* in one go, but you musty manually update the CJS snapshots by running:
|
|
18
|
+
*
|
|
19
|
+
* FRODO_DEBUG=1 npm run test:update AuthenticateOps
|
|
20
|
+
*
|
|
21
|
+
* 3. Test your changes
|
|
22
|
+
*
|
|
23
|
+
* If 1 and 2 didn't produce any errors, you are ready to run the tests in
|
|
24
|
+
* replay mode and make sure they all succeed as well:
|
|
25
|
+
*
|
|
26
|
+
* npm run test AuthenticateOps
|
|
27
|
+
*
|
|
28
|
+
* Note: FRODO_DEBUG=1 is optional and enables debug logging for some output
|
|
29
|
+
* in case things don't function as expected
|
|
30
|
+
*/
|
|
31
|
+
import { jest } from '@jest/globals';
|
|
1
32
|
import { Authenticate, state } from '../index';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
import { autoSetupPolly, defaultMatchRequestsBy } from '../utils/AutoSetupPolly';
|
|
34
|
+
|
|
35
|
+
// need to modify the default matching rules to allow the mocking to work for an authentication flow.
|
|
36
|
+
const matchConfig = defaultMatchRequestsBy();
|
|
37
|
+
matchConfig.body = false; // oauth flows are tricky because of the PKCE challenge, which is different for each request
|
|
38
|
+
matchConfig.order = true; // since we instruct Polly not to match the body, we need to enable ordering of the requests
|
|
39
|
+
|
|
40
|
+
autoSetupPolly(matchConfig);
|
|
41
|
+
|
|
42
|
+
// Increase timeout for this test as pipeline keeps failing with error:
|
|
43
|
+
// Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.
|
|
44
|
+
jest.setTimeout(30000);
|
|
45
|
+
describe('AuthenticateOps', () => {
|
|
46
|
+
describe('getTokens()', () => {
|
|
47
|
+
test('0: Method is implemented', async () => {
|
|
48
|
+
expect(Authenticate.getTokens).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
test('1: Authenticate successfully as user', async () => {
|
|
51
|
+
state.setHost(process.env.FRODO_HOST || 'https://openam-frodo-dev.forgeblocks.com/am');
|
|
52
|
+
state.setRealm(process.env.FRODO_REALM || 'alpha');
|
|
53
|
+
state.setUsername(process.env.FRODO_USERNAME || 'mockUser');
|
|
54
|
+
state.setPassword(process.env.FRODO_PASSWORD || 'mockPassword');
|
|
55
|
+
const result = await Authenticate.getTokens();
|
|
56
|
+
expect(result).toBe(true);
|
|
57
|
+
expect(state.getDeploymentType()).toEqual('cloud');
|
|
58
|
+
expect(state.getCookieName()).toBeTruthy();
|
|
59
|
+
expect(state.getCookieValue()).toBeTruthy();
|
|
60
|
+
expect(state.getBearerToken()).toBeTruthy();
|
|
61
|
+
expect(state.getCookieName()).toMatchSnapshot();
|
|
62
|
+
expect(state.getCookieValue()).toMatchSnapshot();
|
|
63
|
+
expect(state.getBearerToken()).toMatchSnapshot();
|
|
64
|
+
});
|
|
14
65
|
});
|
|
15
66
|
});
|
|
16
67
|
//# sourceMappingURL=AuthenticateOps.test.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
1
2
|
import axios from 'axios';
|
|
2
3
|
import MockAdapter from 'axios-mock-adapter';
|
|
3
4
|
import { state } from '../index';
|
|
@@ -7,6 +8,10 @@ import * as ServiceAccount from './ServiceAccountOps';
|
|
|
7
8
|
import { mockCreateManagedObject } from '../test/mocks/ForgeRockApiMockEngine';
|
|
8
9
|
import { isEqualJson } from './utils/OpsUtils';
|
|
9
10
|
const mock = new MockAdapter(axios);
|
|
11
|
+
|
|
12
|
+
// Increase timeout for this test as pipeline keeps failing with error:
|
|
13
|
+
// Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.
|
|
14
|
+
jest.setTimeout(30000);
|
|
10
15
|
const outputHandler = message => {
|
|
11
16
|
console.log(message);
|
|
12
17
|
};
|
package/esm/shared/State.mjs
CHANGED
|
@@ -4,7 +4,47 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'));
|
|
6
6
|
const _state = {
|
|
7
|
-
authenticationHeaderOverrides: {}
|
|
7
|
+
authenticationHeaderOverrides: {},
|
|
8
|
+
printHandler: message => {
|
|
9
|
+
if (!message) return;
|
|
10
|
+
if (typeof message === 'object') {
|
|
11
|
+
console.dir(message, {
|
|
12
|
+
depth: 3
|
|
13
|
+
});
|
|
14
|
+
} else {
|
|
15
|
+
console.log(message);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
verboseHandler: message => {
|
|
19
|
+
if (!message) return;
|
|
20
|
+
if (getVerbose()) {
|
|
21
|
+
if (typeof message === 'object') {
|
|
22
|
+
console.dir(message, {
|
|
23
|
+
depth: 3
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
console.log(message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
debugHandler: message => {
|
|
31
|
+
if (!message) return;
|
|
32
|
+
if (getDebug()) {
|
|
33
|
+
if (typeof message === 'object') {
|
|
34
|
+
console.dir(message, {
|
|
35
|
+
depth: 6
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
console.log(message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
curlirizeHandler: message => {
|
|
43
|
+
if (!message) return;
|
|
44
|
+
if (getDebug()) {
|
|
45
|
+
console.log(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
8
48
|
};
|
|
9
49
|
export const setHost = host => _state.host = host;
|
|
10
50
|
export const getHost = () => _state.host || process.env.FRODO_HOST;
|
|
@@ -13,30 +13,57 @@ const {
|
|
|
13
13
|
Polly.register(NodeHttpAdapter);
|
|
14
14
|
Polly.register(FSPersister);
|
|
15
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
|
|
17
|
-
state.setRealm('alpha');
|
|
18
|
-
let recordIfMissing = true;
|
|
16
|
+
let recordIfMissing = false;
|
|
19
17
|
let mode = MODES.REPLAY;
|
|
20
18
|
|
|
21
19
|
// resolve "/home/sandeepc/work/ForgeRock/sources/frodo-lib/esm/api" to
|
|
22
20
|
// "/home/sandeepc/work/ForgeRock/sources/frodo-lib/src/test/recordings"
|
|
23
21
|
const recordingsDir = __dirname.replace(/^(.*\/frodo-\w{3})(.*)$/gi, '$1/src/test/mock-recordings');
|
|
24
22
|
switch (process.env.FRODO_POLLY_MODE) {
|
|
23
|
+
// record mock responses from a real env: `npm run test:record`
|
|
25
24
|
case 'record':
|
|
25
|
+
{
|
|
26
|
+
state.setHost(process.env.FRODO_HOST || 'https://openam-frodo-dev.forgeblocks.com/am');
|
|
27
|
+
state.setRealm(process.env.FRODO_REALM || 'alpha');
|
|
28
|
+
if (!(await getTokens())) throw new Error(`Unable to record mock responses from '${state.getHost()}'`);
|
|
29
|
+
mode = MODES.RECORD;
|
|
30
|
+
recordIfMissing = true;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
// record mock responses from authentication APIs (don't authenticate): `npm run test:record_noauth`
|
|
34
|
+
case 'record_noauth':
|
|
26
35
|
mode = MODES.RECORD;
|
|
27
|
-
|
|
28
|
-
break;
|
|
29
|
-
case 'replay':
|
|
30
|
-
mode = MODES.REPLAY;
|
|
31
|
-
state.default.session.setCookieName('cookieName');
|
|
32
|
-
state.default.session.setCookieValue('cookieValue');
|
|
36
|
+
recordIfMissing = true;
|
|
33
37
|
break;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
// replay mock responses: `npm test`
|
|
39
|
+
default:
|
|
40
|
+
state.setHost(process.env.FRODO_HOST || 'https://openam-frodo-dev.forgeblocks.com/am');
|
|
41
|
+
state.setRealm(process.env.FRODO_REALM || 'alpha');
|
|
42
|
+
state.setCookieName('cookieName');
|
|
43
|
+
state.setCookieValue('cookieValue');
|
|
37
44
|
break;
|
|
38
45
|
}
|
|
39
|
-
export
|
|
46
|
+
export function defaultMatchRequestsBy() {
|
|
47
|
+
return JSON.parse(JSON.stringify({
|
|
48
|
+
method: true,
|
|
49
|
+
headers: false,
|
|
50
|
+
// do not match headers, because "Authorization" header is sent only at recording time
|
|
51
|
+
body: true,
|
|
52
|
+
order: false,
|
|
53
|
+
url: {
|
|
54
|
+
protocol: true,
|
|
55
|
+
username: false,
|
|
56
|
+
password: false,
|
|
57
|
+
hostname: false,
|
|
58
|
+
// we will record from different envs but run tests always against `frodo-dev`
|
|
59
|
+
port: false,
|
|
60
|
+
pathname: true,
|
|
61
|
+
query: true,
|
|
62
|
+
hash: true
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
export function autoSetupPolly(matchRequestsBy = defaultMatchRequestsBy()) {
|
|
40
67
|
return setupPolly({
|
|
41
68
|
adapters: ['node-http'],
|
|
42
69
|
mode,
|
|
@@ -50,23 +77,7 @@ export default function autoSetupPolly() {
|
|
|
50
77
|
recordingsDir
|
|
51
78
|
}
|
|
52
79
|
},
|
|
53
|
-
matchRequestsBy
|
|
54
|
-
method: true,
|
|
55
|
-
headers: false,
|
|
56
|
-
// do not match headers, because "Authorization" header is sent only at recording time
|
|
57
|
-
body: true,
|
|
58
|
-
order: false,
|
|
59
|
-
url: {
|
|
60
|
-
protocol: true,
|
|
61
|
-
username: false,
|
|
62
|
-
password: false,
|
|
63
|
-
hostname: true,
|
|
64
|
-
port: false,
|
|
65
|
-
pathname: true,
|
|
66
|
-
query: true,
|
|
67
|
-
hash: true
|
|
68
|
-
}
|
|
69
|
-
}
|
|
80
|
+
matchRequestsBy
|
|
70
81
|
});
|
|
71
82
|
}
|
|
72
83
|
//# sourceMappingURL=AutoSetupPolly.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rockcarver/frodo-lib",
|
|
3
|
-
"version": "0.17.0",
|
|
3
|
+
"version": "0.17.2-0",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"main": "./cjs/index.js",
|
|
6
6
|
"module": "./esm/index.mjs",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"test": "npx gulp && node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --silent",
|
|
17
17
|
"test:only": "node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --silent",
|
|
18
|
-
"test:record": "npm run test:record:esm && npm run test:update:cjs",
|
|
19
|
-
"test:record:esm": "FRODO_POLLY_MODE=record node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --silent esm -u",
|
|
20
|
-
"test:update:cjs": "node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --silent cjs -u",
|
|
21
18
|
"test:debug": "node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --verbose=true --silent=false",
|
|
19
|
+
"test:record": "FRODO_POLLY_MODE=record node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --verbose=true --silent=false --updateSnapshot --testPathIgnorePatterns cjs --testPathPattern",
|
|
20
|
+
"test:record_noauth": "FRODO_POLLY_MODE=record_noauth node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --verbose=true --silent=false --updateSnapshot --testPathIgnorePatterns cjs --testPathPattern",
|
|
21
|
+
"test:update": "node --no-warnings --experimental-vm-modules --experimental-specifier-resolution=node node_modules/jest/bin/jest.js --verbose=true --silent=false --updateSnapshot --testPathIgnorePatterns esm --testPathPattern",
|
|
22
22
|
"lint": "npx eslint --ext .ts --ignore-path .gitignore .",
|
|
23
23
|
"build": "npx gulp",
|
|
24
24
|
"watch": "npx gulp watch"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/ops/AuthenticateOps.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,MAAM,EAAwB,MAAM,WAAW,CAAC;AA+SzD;;;;;GAKG;AACH,wBAAsB,+BAA+B,CACnD,gBAAgB,EAAE,MAAM,EACxB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqBxB;AAkCD;;;;GAIG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CA6GlD","file":"AuthenticateOps.d.ts","sourcesContent":["import url from 'url';\nimport { createHash, randomBytes } from 'crypto';\nimport readlineSync from 'readline-sync';\nimport { encodeBase64Url } from '../api/utils/Base64';\nimport * as state from '../shared/State';\nimport * as globalConfig from '../storage/StaticStorage';\nimport { debugMessage, printMessage, verboseMessage } from './utils/Console';\nimport { getServerInfo, getServerVersionInfo } from '../api/ServerInfoApi';\nimport { step } from '../api/AuthenticateApi';\nimport { accessToken, authorize } from '../api/OAuth2OIDCApi';\nimport { getConnectionProfile } from './ConnectionProfileOps';\nimport { v4 } from 'uuid';\nimport { parseUrl } from '../api/utils/ApiUtils';\nimport { JwkRsa, createSignedJwtToken } from './JoseOps';\nimport { getManagedObject } from '../api/ManagedObjectApi';\n\nconst adminClientPassword = 'doesnotmatter';\nconst redirectUrlTemplate = '/platform/appAuthHelperRedirect.html';\n\nconst idmAdminScopes = 'fr:idm:* openid';\nconst serviceAccountScopes = 'fr:am:* fr:idm:* fr:idc:esv:*';\n\nlet adminClientId = 'idmAdminClient';\n\n/**\n * Helper function to get cookie name\n * @returns {String} cookie name\n */\nasync function determineCookieName() {\n try {\n const { data } = await getServerInfo();\n debugMessage(\n `AuthenticateOps.getCookieName: cookieName=${data.cookieName}`\n );\n return data.cookieName;\n } catch (error) {\n printMessage(`Error getting cookie name: ${error}`, 'error');\n debugMessage(error.stack);\n return null;\n }\n}\n\n/**\n * Helper function to determine if this is a setup mfa prompt in the ID Cloud tenant admin login journey\n * @param {Object} payload response from the previous authentication journey step\n * @returns {Object} an object indicating if 2fa is required and the original payload\n */\nfunction checkAndHandle2FA(payload) {\n // let skippable = false;\n if ('callbacks' in payload) {\n for (const element of payload.callbacks) {\n if (element.type === 'HiddenValueCallback') {\n if (element.input[0].value.includes('skip')) {\n // skippable = true;\n element.input[0].value = 'Skip';\n return {\n need2fa: true,\n payload,\n };\n }\n }\n if (element.type === 'NameCallback') {\n if (element.output[0].value.includes('code')) {\n // skippable = false;\n printMessage('2FA is enabled and required for this user...');\n const code = readlineSync.question(`${element.output[0].value}: `);\n element.input[0].value = code;\n return {\n need2fa: true,\n payload,\n };\n }\n }\n }\n // console.info(\"NO2FA\");\n return {\n need2fa: false,\n payload,\n };\n }\n // console.info(\"NO2FA\");\n return {\n need2fa: false,\n payload,\n };\n}\n\n/**\n * Helper function to set the default realm by deployment type\n * @param {string} deploymentType deployment type\n */\nfunction determineDefaultRealm(deploymentType: string) {\n if (state.getRealm() === globalConfig.DEFAULT_REALM_KEY) {\n state.setRealm(globalConfig.DEPLOYMENT_TYPE_REALM_MAP[deploymentType]);\n }\n}\n\n/**\n * Helper function to determine the deployment type\n * @returns {Promise<string>} deployment type\n */\nasync function determineDeploymentType(): Promise<string> {\n const cookieValue = state.getCookieValue();\n // https://bugster.forgerock.org/jira/browse/FRAAS-13018\n // There is a chance that this will be blocked due to security concerns and thus is probably best not to keep active\n // if (!cookieValue && getUseBearerTokenForAmApis()) {\n // const token = await getTokenInfo();\n // cookieValue = token.sessionToken;\n // setCookieValue(cookieValue);\n // }\n\n // if we are using a service account, we know it's cloud\n if (state.getUseBearerTokenForAmApis())\n return globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY;\n\n const fidcClientId = 'idmAdminClient';\n const forgeopsClientId = 'idm-admin-ui';\n\n const verifier = encodeBase64Url(randomBytes(32));\n const challenge = encodeBase64Url(\n createHash('sha256').update(verifier).digest()\n );\n const challengeMethod = 'S256';\n const redirectURL = url.resolve(state.getHost(), redirectUrlTemplate);\n\n const config = {\n maxRedirects: 0,\n headers: {\n [state.getCookieName()]: state.getCookieValue(),\n },\n };\n let bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${fidcClientId}&csrf=${cookieValue}&decision=allow&code_challenge=${challenge}&code_challenge_method=${challengeMethod}`;\n\n let deploymentType = globalConfig.CLASSIC_DEPLOYMENT_TYPE_KEY;\n try {\n await authorize(bodyFormData, config);\n } catch (e) {\n // debugMessage(e.response);\n if (\n e.response?.status === 302 &&\n e.response.headers?.location?.indexOf('code=') > -1\n ) {\n verboseMessage(`ForgeRock Identity Cloud`['brightCyan'] + ` detected.`);\n deploymentType = globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY;\n } else {\n try {\n bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${forgeopsClientId}&csrf=${state.getCookieValue()}&decision=allow&code_challenge=${challenge}&code_challenge_method=${challengeMethod}`;\n await authorize(bodyFormData, config);\n } catch (ex) {\n if (\n ex.response?.status === 302 &&\n ex.response.headers?.location?.indexOf('code=') > -1\n ) {\n adminClientId = forgeopsClientId;\n verboseMessage(`ForgeOps deployment`['brightCyan'] + ` detected.`);\n deploymentType = globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY;\n } else {\n verboseMessage(`Classic deployment`['brightCyan'] + ` detected.`);\n }\n }\n }\n }\n return deploymentType;\n}\n\n/**\n * Helper function to extract the semantic version string from a version info object\n * @param {Object} versionInfo version info object\n * @returns {String} semantic version\n */\nasync function getSemanticVersion(versionInfo) {\n if ('version' in versionInfo) {\n const versionString = versionInfo.version;\n const rx = /([\\d]\\.[\\d]\\.[\\d](\\.[\\d])*)/g;\n const version = versionString.match(rx);\n return version[0];\n }\n throw new Error('Cannot extract semantic version from version info object.');\n}\n\n/**\n * Helper function to authenticate and obtain and store session cookie\n * @returns {string} Session token or null\n */\nasync function authenticate(\n username: string,\n password: string\n): Promise<string> {\n const config = {\n headers: {\n 'X-OpenAM-Username': username,\n 'X-OpenAM-Password': password,\n },\n };\n const response1 = await step({}, config);\n const skip2FA = checkAndHandle2FA(response1);\n let response2 = {};\n if (skip2FA.need2fa) {\n response2 = await step(skip2FA.payload);\n } else {\n response2 = skip2FA.payload;\n }\n if ('tokenId' in response2) {\n return response2['tokenId'] as string;\n }\n return null;\n}\n\n/**\n * Helper function to obtain an oauth2 authorization code\n * @param {string} redirectURL oauth2 redirect uri\n * @param {string} codeChallenge PKCE code challenge\n * @param {string} codeChallengeMethod PKCE code challenge method\n * @returns {string} oauth2 authorization code or null\n */\nasync function getAuthCode(redirectURL, codeChallenge, codeChallengeMethod) {\n try {\n const bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${adminClientId}&csrf=${state.getCookieValue()}&decision=allow&code_challenge=${codeChallenge}&code_challenge_method=${codeChallengeMethod}`;\n const config = {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n maxRedirects: 0,\n };\n let response = undefined;\n try {\n response = await authorize(bodyFormData, config);\n } catch (error) {\n response = error.response;\n }\n if (response.status < 200 || response.status > 399) {\n printMessage('error getting auth code', 'error');\n printMessage(\n 'likely cause: mismatched parameters with OAuth client config',\n 'error'\n );\n return null;\n }\n const redirectLocationURL = response.headers?.location;\n const queryObject = url.parse(redirectLocationURL, true).query;\n if ('code' in queryObject) {\n return queryObject.code;\n }\n printMessage('auth code not found', 'error');\n return null;\n } catch (error) {\n printMessage(`error getting auth code - ${error.message}`, 'error');\n printMessage(error.response?.data, 'error');\n debugMessage(error.stack);\n return null;\n }\n}\n\n/**\n * Helper function to obtain oauth2 access token\n * @returns {Promise<string | null>} access token or null\n */\nasync function getAccessTokenForUser(): Promise<string | null> {\n try {\n const verifier = encodeBase64Url(randomBytes(32));\n const challenge = encodeBase64Url(\n createHash('sha256').update(verifier).digest()\n );\n const challengeMethod = 'S256';\n const redirectURL = url.resolve(state.getHost(), redirectUrlTemplate);\n const authCode = await getAuthCode(redirectURL, challenge, challengeMethod);\n if (authCode == null) {\n printMessage('error getting auth code', 'error');\n return null;\n }\n let response = null;\n if (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY) {\n const config = {\n auth: {\n username: adminClientId,\n password: adminClientPassword,\n },\n };\n const bodyFormData = `redirect_uri=${redirectURL}&grant_type=authorization_code&code=${authCode}&code_verifier=${verifier}`;\n response = await accessToken(bodyFormData, config);\n } else {\n const bodyFormData = `client_id=${adminClientId}&redirect_uri=${redirectURL}&grant_type=authorization_code&code=${authCode}&code_verifier=${verifier}`;\n response = await accessToken(bodyFormData);\n }\n if ('access_token' in response.data) {\n return response.data.access_token;\n }\n printMessage('No access token in response.', 'error');\n } catch (error) {\n debugMessage(`Error getting access token for user: ${error}`);\n debugMessage(error.response?.data);\n }\n return null;\n}\n\nfunction createPayload(serviceAccountId: string) {\n const u = parseUrl(state.getHost());\n const aud = `${u.origin}:${\n u.port ? u.port : u.protocol === 'https' ? '443' : '80'\n }${u.pathname}/oauth2/access_token`;\n\n // Cross platform way of setting JWT expiry time 3 minutes in the future, expressed as number of seconds since EPOCH\n const exp = Math.floor(new Date().getTime() / 1000 + 180);\n\n // A unique ID for the JWT which is required when requesting the openid scope\n const jti = v4();\n\n const iss = serviceAccountId;\n const sub = serviceAccountId;\n\n // Create the payload for our bearer token\n const payload = { iss, sub, aud, exp, jti };\n\n return payload;\n}\n\n/**\n * Get access token for service account\n * @param {string} serviceAccountId UUID of service account\n * @param {JwkRsa} jwk Java Wek Key\n * @returns {string | null} Access token or null\n */\nexport async function getAccessTokenForServiceAccount(\n serviceAccountId: string,\n jwk: JwkRsa\n): Promise<string | null> {\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: start`);\n const payload = createPayload(serviceAccountId);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: payload:`);\n debugMessage(payload);\n const jwt = await createSignedJwtToken(payload, jwk);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: jwt:`);\n debugMessage(jwt);\n const bodyFormData = `assertion=${jwt}&client_id=service-account&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=${serviceAccountScopes}`;\n const response = await accessToken(bodyFormData);\n if ('access_token' in response.data) {\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: token:`);\n debugMessage(response.data.access_token);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: end`);\n return response.data.access_token;\n }\n debugMessage(\n `AuthenticateOps.getAccessTokenForServiceAccount: No access token in response.`\n );\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: end`);\n return null;\n}\n\nasync function determineDeploymentTypeAndDefaultRealmAndVersion() {\n debugMessage(\n `AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: start`\n );\n if (!state.getDeploymentType()) {\n state.setDeploymentType(await determineDeploymentType());\n }\n determineDefaultRealm(state.getDeploymentType());\n\n const versionInfo = (await getServerVersionInfo()).data;\n\n // https://github.com/rockcarver/frodo-cli/issues/109\n debugMessage(`Full version: ${versionInfo.fullVersion}`);\n\n const version = await getSemanticVersion(versionInfo);\n state.setAmVersion(version);\n debugMessage(\n `AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: end`\n );\n}\n\nasync function getLoggedInSubject(): Promise<string> {\n let subjectString = `user ${state.getUsername()}`;\n if (state.getUseBearerTokenForAmApis()) {\n const name = (\n await getManagedObject('svcacct', state.getServiceAccountId(), ['name'])\n ).data.name;\n subjectString = `service account ${name} [${state.getServiceAccountId()}]`;\n }\n return subjectString;\n}\n\n/**\n * Get tokens\n * @param {boolean} save true to save a connection profile upon successful authentication, false otherwise\n * @returns {Promise<boolean>} true if tokens were successfully obtained, false otherwise\n */\nexport async function getTokens(): Promise<boolean> {\n if (!state.getHost()) {\n printMessage(\n `No host specified and FRODO_HOST env variable not set!`,\n 'error'\n );\n return false;\n }\n try {\n // if username/password on cli are empty, try to read from connections.json\n if (\n state.getUsername() == null &&\n state.getPassword() == null &&\n !state.getServiceAccountId() &&\n !state.getServiceAccountJwk()\n ) {\n const conn = await getConnectionProfile();\n if (conn) {\n state.setHost(conn.tenant);\n state.setUsername(conn.username);\n state.setPassword(conn.password);\n state.setAuthenticationService(conn.authenticationService);\n state.setAuthenticationHeaderOverrides(\n conn.authenticationHeaderOverrides\n );\n state.setServiceAccountId(conn.svcacctId);\n state.setServiceAccountJwk(conn.svcacctJwk);\n } else {\n return false;\n }\n }\n // now that we have the full tenant URL we can lookup the cookie name\n state.setCookieName(await determineCookieName());\n\n // use service account to login?\n if (state.getServiceAccountId() && state.getServiceAccountJwk()) {\n debugMessage(\n `AuthenticateOps.getTokens: Authenticating with service account ${state.getServiceAccountId()}`\n );\n try {\n const token = await getAccessTokenForServiceAccount(\n state.getServiceAccountId(),\n state.getServiceAccountJwk()\n );\n state.setBearerToken(token);\n state.setUseBearerTokenForAmApis(true);\n await determineDeploymentTypeAndDefaultRealmAndVersion();\n } catch (saErr) {\n throw new Error(\n `Service account login error: ${\n saErr.response?.data?.error_description ||\n saErr.response?.data?.message\n }`\n );\n }\n }\n // use user account to login\n else if (state.getUsername() && state.getPassword()) {\n debugMessage(\n `AuthenticateOps.getTokens: Authenticating with user account ${state.getUsername()}`\n );\n const token = await authenticate(\n state.getUsername(),\n state.getPassword()\n );\n if (token) state.setCookieValue(token);\n await determineDeploymentTypeAndDefaultRealmAndVersion();\n if (\n state.getCookieValue() &&\n !state.getBearerToken() &&\n (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY ||\n state.getDeploymentType() ===\n globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY)\n ) {\n const accessToken = await getAccessTokenForUser();\n if (accessToken) state.setBearerToken(accessToken);\n }\n }\n // incomplete or no credentials\n else {\n printMessage(`Incomplete or no credentials!`, 'error');\n return false;\n }\n if (\n state.getCookieValue() ||\n (state.getUseBearerTokenForAmApis() && state.getBearerToken())\n ) {\n // https://github.com/rockcarver/frodo-cli/issues/102\n printMessage(\n `Connected to ${state.getHost()} [${\n state.getRealm() ? state.getRealm() : 'root'\n }] as ${await getLoggedInSubject()}`,\n 'info'\n );\n return true;\n }\n } catch (error) {\n // regular error\n printMessage(error.message, 'error');\n // axios error am api\n printMessage(error.response?.data?.message, 'error');\n // axios error am oauth2 api\n printMessage(error.response?.data?.error_description, 'error');\n // axios error data\n debugMessage(error.response?.data);\n // stack trace\n debugMessage(error.stack || new Error().stack);\n }\n return false;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/ops/AuthenticateOps.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,MAAM,EAAwB,MAAM,WAAW,CAAC;AAqTzD;;;;;GAKG;AACH,wBAAsB,+BAA+B,CACnD,gBAAgB,EAAE,MAAM,EACxB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqBxB;AAqCD;;;;GAIG;AACH,wBAAsB,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC,CAsIlD","file":"AuthenticateOps.d.ts","sourcesContent":["import url from 'url';\nimport { createHash, randomBytes } from 'crypto';\nimport readlineSync from 'readline-sync';\nimport { encodeBase64Url } from '../api/utils/Base64';\nimport * as state from '../shared/State';\nimport * as globalConfig from '../storage/StaticStorage';\nimport { debugMessage, printMessage, verboseMessage } from './utils/Console';\nimport { getServerInfo, getServerVersionInfo } from '../api/ServerInfoApi';\nimport { step } from '../api/AuthenticateApi';\nimport { accessToken, authorize } from '../api/OAuth2OIDCApi';\nimport { getConnectionProfile } from './ConnectionProfileOps';\nimport { v4 } from 'uuid';\nimport { parseUrl } from '../api/utils/ApiUtils';\nimport { JwkRsa, createSignedJwtToken } from './JoseOps';\nimport { getManagedObject } from '../api/ManagedObjectApi';\n\nconst adminClientPassword = 'doesnotmatter';\nconst redirectUrlTemplate = '/platform/appAuthHelperRedirect.html';\n\nconst idmAdminScopes = 'fr:idm:* openid';\nconst serviceAccountScopes = 'fr:am:* fr:idm:* fr:idc:esv:*';\n\nlet adminClientId = 'idmAdminClient';\n\n/**\n * Helper function to get cookie name\n * @returns {String} cookie name\n */\nasync function determineCookieName() {\n try {\n const { data } = await getServerInfo();\n debugMessage(\n `AuthenticateOps.getCookieName: cookieName=${data.cookieName}`\n );\n return data.cookieName;\n } catch (error) {\n printMessage(`Error getting cookie name: ${error}`, 'error');\n debugMessage(error.stack);\n return null;\n }\n}\n\n/**\n * Helper function to determine if this is a setup mfa prompt in the ID Cloud tenant admin login journey\n * @param {Object} payload response from the previous authentication journey step\n * @returns {Object} an object indicating if 2fa is required and the original payload\n */\nfunction checkAndHandle2FA(payload) {\n // let skippable = false;\n if ('callbacks' in payload) {\n for (const element of payload.callbacks) {\n if (element.type === 'HiddenValueCallback') {\n if (element.input[0].value.includes('skip')) {\n // skippable = true;\n element.input[0].value = 'Skip';\n return {\n need2fa: true,\n payload,\n };\n }\n }\n if (element.type === 'NameCallback') {\n if (element.output[0].value.includes('code')) {\n // skippable = false;\n printMessage('2FA is enabled and required for this user...');\n const code = readlineSync.question(`${element.output[0].value}: `);\n element.input[0].value = code;\n return {\n need2fa: true,\n payload,\n };\n }\n }\n }\n // console.info(\"NO2FA\");\n return {\n need2fa: false,\n payload,\n };\n }\n // console.info(\"NO2FA\");\n return {\n need2fa: false,\n payload,\n };\n}\n\n/**\n * Helper function to set the default realm by deployment type\n * @param {string} deploymentType deployment type\n */\nfunction determineDefaultRealm(deploymentType: string) {\n if (\n !state.getRealm() ||\n state.getRealm() === globalConfig.DEFAULT_REALM_KEY\n ) {\n state.setRealm(globalConfig.DEPLOYMENT_TYPE_REALM_MAP[deploymentType]);\n }\n}\n\n/**\n * Helper function to determine the deployment type\n * @returns {Promise<string>} deployment type\n */\nasync function determineDeploymentType(): Promise<string> {\n const cookieValue = state.getCookieValue();\n // https://bugster.forgerock.org/jira/browse/FRAAS-13018\n // There is a chance that this will be blocked due to security concerns and thus is probably best not to keep active\n // if (!cookieValue && getUseBearerTokenForAmApis()) {\n // const token = await getTokenInfo();\n // cookieValue = token.sessionToken;\n // setCookieValue(cookieValue);\n // }\n\n // if we are using a service account, we know it's cloud\n if (state.getUseBearerTokenForAmApis())\n return globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY;\n\n const fidcClientId = 'idmAdminClient';\n const forgeopsClientId = 'idm-admin-ui';\n\n const verifier = encodeBase64Url(randomBytes(32));\n const challenge = encodeBase64Url(\n createHash('sha256').update(verifier).digest()\n );\n const challengeMethod = 'S256';\n const redirectURL = url.resolve(state.getHost(), redirectUrlTemplate);\n\n const config = {\n maxRedirects: 0,\n headers: {\n [state.getCookieName()]: state.getCookieValue(),\n },\n };\n let bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${fidcClientId}&csrf=${cookieValue}&decision=allow&code_challenge=${challenge}&code_challenge_method=${challengeMethod}`;\n\n let deploymentType = globalConfig.CLASSIC_DEPLOYMENT_TYPE_KEY;\n try {\n await authorize(bodyFormData, config);\n } catch (e) {\n // debugMessage(e.response);\n if (\n e.response?.status === 302 &&\n e.response.headers?.location?.indexOf('code=') > -1\n ) {\n verboseMessage(`ForgeRock Identity Cloud`['brightCyan'] + ` detected.`);\n deploymentType = globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY;\n } else {\n try {\n bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${forgeopsClientId}&csrf=${state.getCookieValue()}&decision=allow&code_challenge=${challenge}&code_challenge_method=${challengeMethod}`;\n await authorize(bodyFormData, config);\n } catch (ex) {\n if (\n ex.response?.status === 302 &&\n ex.response.headers?.location?.indexOf('code=') > -1\n ) {\n adminClientId = forgeopsClientId;\n verboseMessage(`ForgeOps deployment`['brightCyan'] + ` detected.`);\n deploymentType = globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY;\n } else {\n verboseMessage(`Classic deployment`['brightCyan'] + ` detected.`);\n }\n }\n }\n }\n return deploymentType;\n}\n\n/**\n * Helper function to extract the semantic version string from a version info object\n * @param {Object} versionInfo version info object\n * @returns {String} semantic version\n */\nasync function getSemanticVersion(versionInfo) {\n if ('version' in versionInfo) {\n const versionString = versionInfo.version;\n const rx = /([\\d]\\.[\\d]\\.[\\d](\\.[\\d])*)/g;\n const version = versionString.match(rx);\n return version[0];\n }\n throw new Error('Cannot extract semantic version from version info object.');\n}\n\n/**\n * Helper function to authenticate and obtain and store session cookie\n * @returns {string} Session token or null\n */\nasync function authenticate(\n username: string,\n password: string\n): Promise<string> {\n const config = {\n headers: {\n 'X-OpenAM-Username': username,\n 'X-OpenAM-Password': password,\n },\n };\n const response1 = await step({}, config);\n const skip2FA = checkAndHandle2FA(response1);\n let response2 = {};\n if (skip2FA.need2fa) {\n response2 = await step(skip2FA.payload);\n } else {\n response2 = skip2FA.payload;\n }\n if ('tokenId' in response2) {\n return response2['tokenId'] as string;\n }\n return null;\n}\n\n/**\n * Helper function to obtain an oauth2 authorization code\n * @param {string} redirectURL oauth2 redirect uri\n * @param {string} codeChallenge PKCE code challenge\n * @param {string} codeChallengeMethod PKCE code challenge method\n * @returns {string} oauth2 authorization code or null\n */\nasync function getAuthCode(redirectURL, codeChallenge, codeChallengeMethod) {\n try {\n const bodyFormData = `redirect_uri=${redirectURL}&scope=${idmAdminScopes}&response_type=code&client_id=${adminClientId}&csrf=${state.getCookieValue()}&decision=allow&code_challenge=${codeChallenge}&code_challenge_method=${codeChallengeMethod}`;\n const config = {\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n maxRedirects: 0,\n };\n let response = undefined;\n try {\n response = await authorize(bodyFormData, config);\n } catch (error) {\n response = error.response;\n }\n if (response.status < 200 || response.status > 399) {\n printMessage('error getting auth code', 'error');\n printMessage(\n 'likely cause: mismatched parameters with OAuth client config',\n 'error'\n );\n return null;\n }\n const redirectLocationURL = response.headers?.location;\n const queryObject = url.parse(redirectLocationURL, true).query;\n if ('code' in queryObject) {\n return queryObject.code;\n }\n printMessage('auth code not found', 'error');\n return null;\n } catch (error) {\n printMessage(`error getting auth code - ${error.message}`, 'error');\n printMessage(error.response?.data, 'error');\n debugMessage(error.stack);\n return null;\n }\n}\n\n/**\n * Helper function to obtain oauth2 access token\n * @returns {Promise<string | null>} access token or null\n */\nasync function getAccessTokenForUser(): Promise<string | null> {\n debugMessage(`AuthenticateOps.getAccessTokenForUser: start`);\n try {\n const verifier = encodeBase64Url(randomBytes(32));\n const challenge = encodeBase64Url(\n createHash('sha256').update(verifier).digest()\n );\n const challengeMethod = 'S256';\n const redirectURL = url.resolve(state.getHost(), redirectUrlTemplate);\n const authCode = await getAuthCode(redirectURL, challenge, challengeMethod);\n if (authCode == null) {\n printMessage('error getting auth code', 'error');\n return null;\n }\n let response = null;\n if (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY) {\n const config = {\n auth: {\n username: adminClientId,\n password: adminClientPassword,\n },\n };\n const bodyFormData = `redirect_uri=${redirectURL}&grant_type=authorization_code&code=${authCode}&code_verifier=${verifier}`;\n response = await accessToken(bodyFormData, config);\n } else {\n const bodyFormData = `client_id=${adminClientId}&redirect_uri=${redirectURL}&grant_type=authorization_code&code=${authCode}&code_verifier=${verifier}`;\n response = await accessToken(bodyFormData);\n }\n if ('access_token' in response.data) {\n debugMessage(`AuthenticateOps.getAccessTokenForUser: end with token`);\n return response.data.access_token;\n }\n printMessage('No access token in response.', 'error');\n } catch (error) {\n debugMessage(`Error getting access token for user: ${error}`);\n debugMessage(error.response?.data);\n }\n debugMessage(`AuthenticateOps.getAccessTokenForUser: end without token`);\n return null;\n}\n\nfunction createPayload(serviceAccountId: string) {\n const u = parseUrl(state.getHost());\n const aud = `${u.origin}:${\n u.port ? u.port : u.protocol === 'https' ? '443' : '80'\n }${u.pathname}/oauth2/access_token`;\n\n // Cross platform way of setting JWT expiry time 3 minutes in the future, expressed as number of seconds since EPOCH\n const exp = Math.floor(new Date().getTime() / 1000 + 180);\n\n // A unique ID for the JWT which is required when requesting the openid scope\n const jti = v4();\n\n const iss = serviceAccountId;\n const sub = serviceAccountId;\n\n // Create the payload for our bearer token\n const payload = { iss, sub, aud, exp, jti };\n\n return payload;\n}\n\n/**\n * Get access token for service account\n * @param {string} serviceAccountId UUID of service account\n * @param {JwkRsa} jwk Java Wek Key\n * @returns {string | null} Access token or null\n */\nexport async function getAccessTokenForServiceAccount(\n serviceAccountId: string,\n jwk: JwkRsa\n): Promise<string | null> {\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: start`);\n const payload = createPayload(serviceAccountId);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: payload:`);\n debugMessage(payload);\n const jwt = await createSignedJwtToken(payload, jwk);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: jwt:`);\n debugMessage(jwt);\n const bodyFormData = `assertion=${jwt}&client_id=service-account&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=${serviceAccountScopes}`;\n const response = await accessToken(bodyFormData);\n if ('access_token' in response.data) {\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: token:`);\n debugMessage(response.data.access_token);\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: end`);\n return response.data.access_token;\n }\n debugMessage(\n `AuthenticateOps.getAccessTokenForServiceAccount: No access token in response.`\n );\n debugMessage(`AuthenticateOps.getAccessTokenForServiceAccount: end`);\n return null;\n}\n\nasync function determineDeploymentTypeAndDefaultRealmAndVersion() {\n debugMessage(\n `AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: start`\n );\n if (!state.getDeploymentType()) {\n state.setDeploymentType(await determineDeploymentType());\n }\n determineDefaultRealm(state.getDeploymentType());\n debugMessage(\n `AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: realm=${state.getRealm()}, type=${state.getDeploymentType()}`\n );\n\n const versionInfo = (await getServerVersionInfo()).data;\n\n // https://github.com/rockcarver/frodo-cli/issues/109\n debugMessage(`Full version: ${versionInfo.fullVersion}`);\n\n const version = await getSemanticVersion(versionInfo);\n state.setAmVersion(version);\n debugMessage(\n `AuthenticateOps.determineDeploymentTypeAndDefaultRealmAndVersion: end`\n );\n}\n\nasync function getLoggedInSubject(): Promise<string> {\n let subjectString = `user ${state.getUsername()}`;\n if (state.getUseBearerTokenForAmApis()) {\n const name = (\n await getManagedObject('svcacct', state.getServiceAccountId(), ['name'])\n ).data.name;\n subjectString = `service account ${name} [${state.getServiceAccountId()}]`;\n }\n return subjectString;\n}\n\n/**\n * Get tokens\n * @param {boolean} save true to save a connection profile upon successful authentication, false otherwise\n * @returns {Promise<boolean>} true if tokens were successfully obtained, false otherwise\n */\nexport async function getTokens(): Promise<boolean> {\n debugMessage(`AuthenticateOps.getTokens: start`);\n if (!state.getHost()) {\n printMessage(\n `No host specified and FRODO_HOST env variable not set!`,\n 'error'\n );\n return false;\n }\n try {\n // if username/password on cli are empty, try to read from connections.json\n if (\n state.getUsername() == null &&\n state.getPassword() == null &&\n !state.getServiceAccountId() &&\n !state.getServiceAccountJwk()\n ) {\n const conn = await getConnectionProfile();\n if (conn) {\n state.setHost(conn.tenant);\n state.setUsername(conn.username);\n state.setPassword(conn.password);\n state.setAuthenticationService(conn.authenticationService);\n state.setAuthenticationHeaderOverrides(\n conn.authenticationHeaderOverrides\n );\n state.setServiceAccountId(conn.svcacctId);\n state.setServiceAccountJwk(conn.svcacctJwk);\n } else {\n return false;\n }\n }\n // now that we have the full tenant URL we can lookup the cookie name\n state.setCookieName(await determineCookieName());\n\n // use service account to login?\n if (state.getServiceAccountId() && state.getServiceAccountJwk()) {\n debugMessage(\n `AuthenticateOps.getTokens: Authenticating with service account ${state.getServiceAccountId()}`\n );\n try {\n const token = await getAccessTokenForServiceAccount(\n state.getServiceAccountId(),\n state.getServiceAccountJwk()\n );\n state.setBearerToken(token);\n state.setUseBearerTokenForAmApis(true);\n await determineDeploymentTypeAndDefaultRealmAndVersion();\n } catch (saErr) {\n throw new Error(\n `Service account login error: ${\n saErr.response?.data?.error_description ||\n saErr.response?.data?.message\n }`\n );\n }\n }\n // use user account to login\n else if (state.getUsername() && state.getPassword()) {\n debugMessage(\n `AuthenticateOps.getTokens: Authenticating with user account ${state.getUsername()}`\n );\n const token = await authenticate(\n state.getUsername(),\n state.getPassword()\n );\n if (token) state.setCookieValue(token);\n await determineDeploymentTypeAndDefaultRealmAndVersion();\n debugMessage(`AuthenticateOps.getTokens: before bearer`);\n debugMessage(\n `AuthenticateOps.getTokens: cookie=${state.getCookieValue()}`\n );\n debugMessage(\n `AuthenticateOps.getTokens: bearer=${state.getBearerToken()}`\n );\n debugMessage(\n `AuthenticateOps.getTokens: type=${state.getDeploymentType()}`\n );\n debugMessage(\n `AuthenticateOps.getTokens: condition=${\n state.getCookieValue() &&\n !state.getBearerToken() &&\n (state.getDeploymentType() ===\n globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY ||\n state.getDeploymentType() ===\n globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY)\n }`\n );\n if (\n state.getCookieValue() &&\n !state.getBearerToken() &&\n (state.getDeploymentType() === globalConfig.CLOUD_DEPLOYMENT_TYPE_KEY ||\n state.getDeploymentType() ===\n globalConfig.FORGEOPS_DEPLOYMENT_TYPE_KEY)\n ) {\n debugMessage(`AuthenticateOps.getTokens: in bearer`);\n const accessToken = await getAccessTokenForUser();\n if (accessToken) state.setBearerToken(accessToken);\n }\n debugMessage(`AuthenticateOps.getTokens: after bearer`);\n }\n // incomplete or no credentials\n else {\n printMessage(`Incomplete or no credentials!`, 'error');\n return false;\n }\n if (\n state.getCookieValue() ||\n (state.getUseBearerTokenForAmApis() && state.getBearerToken())\n ) {\n // https://github.com/rockcarver/frodo-cli/issues/102\n printMessage(\n `Connected to ${state.getHost()} [${\n state.getRealm() ? state.getRealm() : 'root'\n }] as ${await getLoggedInSubject()}`,\n 'info'\n );\n debugMessage(`AuthenticateOps.getTokens: end with tokens`);\n return true;\n }\n } catch (error) {\n // regular error\n printMessage(error.message, 'error');\n // axios error am api\n printMessage(error.response?.data?.message, 'error');\n // axios error am oauth2 api\n printMessage(error.response?.data?.error_description, 'error');\n // axios error data\n debugMessage(error.response?.data);\n // stack trace\n debugMessage(error.stack || new Error().stack);\n }\n debugMessage(`AuthenticateOps.getTokens: end without tokens`);\n return false;\n}\n"]}
|