keycloak-api-manager 3.2.1 → 4.0.1

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.
Files changed (53) hide show
  1. package/.env.example +27 -0
  2. package/Handlers/clientsHandler.js +240 -30
  3. package/Handlers/groupsHandler.js +16 -1
  4. package/Handlers/httpApiHelper.js +87 -0
  5. package/Handlers/realmsHandler.js +26 -4
  6. package/Handlers/usersHandler.js +43 -10
  7. package/README.md +341 -10
  8. package/index.js +159 -29
  9. package/package.json +3 -14
  10. package/test/.mocharc.json +4 -0
  11. package/test/TESTING.md +327 -0
  12. package/test/config/CONFIGURATION.md +170 -0
  13. package/test/config/default.json +36 -0
  14. package/test/config/local.json.example +7 -0
  15. package/test/config/secrets.json.example +7 -0
  16. package/test/diagnostic-protocol-mappers.js +189 -0
  17. package/test/docker-keycloak/DEPLOYMENT_GUIDE.md +262 -0
  18. package/test/docker-keycloak/certs/.gitkeep +7 -0
  19. package/test/docker-keycloak/docker-compose-https.yml +50 -0
  20. package/test/docker-keycloak/docker-compose.yml +59 -0
  21. package/test/docker-keycloak/setup-keycloak.js +501 -0
  22. package/test/enableServerFeatures.js +315 -0
  23. package/test/helpers/config.js +218 -0
  24. package/test/helpers/docker-helpers.js +513 -0
  25. package/test/helpers/setup.js +186 -0
  26. package/test/package.json +18 -0
  27. package/test/setup.js +194 -0
  28. package/test/specs/authenticationManagement.test.js +224 -0
  29. package/test/specs/clientCredentials.test.js +76 -0
  30. package/test/specs/clientScopes.test.js +388 -0
  31. package/test/specs/clients.test.js +791 -0
  32. package/test/specs/components.test.js +151 -0
  33. package/test/specs/debugClientLibrary.test.js +88 -0
  34. package/test/specs/groups.test.js +362 -0
  35. package/test/specs/identityProviders.test.js +292 -0
  36. package/test/specs/realms.test.js +390 -0
  37. package/test/specs/roles.test.js +322 -0
  38. package/test/specs/users.test.js +445 -0
  39. package/test/testConfig.js +69 -0
  40. package/.mocharc.json +0 -7
  41. package/docker-compose.yml +0 -27
  42. package/test/authenticationManagement.test.js +0 -329
  43. package/test/clientScopes.test.js +0 -256
  44. package/test/clients.test.js +0 -284
  45. package/test/components.test.js +0 -122
  46. package/test/config.js +0 -137
  47. package/test/docker-helpers.js +0 -111
  48. package/test/groups.test.js +0 -284
  49. package/test/identityProviders.test.js +0 -197
  50. package/test/mocha.env.js +0 -55
  51. package/test/realms.test.js +0 -349
  52. package/test/roles.test.js +0 -215
  53. package/test/users.test.js +0 -405
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "keycloak-api-manager-tests",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "test": "NODE_ENV=test NODE_PATH=./node_modules mocha --exit"
7
+ },
8
+ "dependencies": {
9
+ "@keycloak/keycloak-admin-client": "^26.5.3",
10
+ "chai": "^4.3.10",
11
+ "express": "^5.1.0",
12
+ "keycloak-api-manager": "file:..",
13
+ "keycloak-connect": "^26.1.1",
14
+ "mocha": "^10.2.0",
15
+ "propertiesmanager": "^4.1.0",
16
+ "request": "^2.88.2"
17
+ }
18
+ }
package/test/setup.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Global Test Setup - Mocha Hooks
3
+ *
4
+ * This file is loaded by Mocha before any tests run (configured in .mocharc.json).
5
+ *
6
+ * Purpose:
7
+ * - Creates shared test realm infrastructure once before all tests
8
+ * - Avoids repeated realm creation/deletion, improving test performance by ~5x
9
+ * - Ensures consistent test environment across all test suites
10
+ * - Automatically creates SSH tunnel if direct connection fails
11
+ *
12
+ * What gets created:
13
+ * - Test realm (keycloak-api-manager-test-realm)
14
+ * - Test client with proper configuration
15
+ * - Test user with credentials
16
+ * - Test roles (test-role-1, test-role-2, test-admin-role)
17
+ * - Test group
18
+ * - Test client scope
19
+ *
20
+ * SSH Tunnel Retry Logic:
21
+ * - First attempts direct connection to 127.0.0.1:9998
22
+ * - If connection refused, automatically creates SSH tunnel to smart-dell-sml.crs4.it
23
+ * - If SSH tunnel also fails, provides troubleshooting steps
24
+ *
25
+ * Note: Individual tests should create unique resources (e.g., user-${timestamp})
26
+ * to avoid conflicts when multiple tests run in parallel or sequence.
27
+ */
28
+
29
+ const enableServerFeatures = require('./enableServerFeatures');
30
+ const { exec } = require('child_process');
31
+ const path = require('path');
32
+ const fs = require('fs');
33
+ const os = require('os');
34
+ const keycloakManager = require('../index');
35
+
36
+ let sshTunnelProcess = null;
37
+
38
+ /**
39
+ * Create SSH tunnel for Keycloak access
40
+ */
41
+ function createSSHTunnelSimple() {
42
+ return new Promise((resolve, reject) => {
43
+ const sshUser = 'smart';
44
+ const sshHost = 'smart-dell-sml.crs4.it';
45
+ const localPort = 9998;
46
+ const remotePort = 8080;
47
+ const homeDir = os.homedir();
48
+ const keyPath = `${homeDir}/.ssh/id_ed25519`;
49
+
50
+ // Create tunnel in background
51
+ const cmd = `ssh -fN -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L 127.0.0.1:${localPort}:127.0.0.1:${remotePort} -i ${keyPath} ${sshUser}@${sshHost}`;
52
+
53
+ exec(cmd, (err, stdout, stderr) => {
54
+ if (err) {
55
+ reject(new Error(`SSH tunnel failed: ${err.message}`));
56
+ } else {
57
+ // Give tunnel time to establish
58
+ setTimeout(() => resolve(`127.0.0.1:${localPort}`), 500);
59
+ }
60
+ });
61
+ });
62
+ }
63
+
64
+ // Global before hook - runs once before all test suites
65
+ before(async function() {
66
+ // Increase timeout - realm creation may take 10-20 seconds
67
+ this.timeout(120000);
68
+
69
+ console.log('\n=== Running global test setup ===\n');
70
+
71
+ try {
72
+ await enableServerFeatures();
73
+ } catch (err) {
74
+ // Extract the root cause message (might be nested in error chain)
75
+ let rootMessage = err.message || '';
76
+ let currentErr = err;
77
+ while (currentErr?.cause) {
78
+ currentErr = currentErr.cause;
79
+ rootMessage = currentErr.message || rootMessage;
80
+ }
81
+
82
+ // Check if connection was refused on 127.0.0.1:9998
83
+ if (rootMessage.includes('ECONNREFUSED') && rootMessage.includes('127.0.0.1:9998')) {
84
+ console.log('\n⚠ Direct connection failed (ECONNREFUSED on 127.0.0.1:9998)');
85
+ console.log(' Attempting to create SSH tunnel to smart-dell-sml.crs4.it...\n');
86
+
87
+ try {
88
+ // Create SSH tunnel
89
+ const sshTunnelUrl = await createSSHTunnelSimple();
90
+
91
+ if (sshTunnelUrl) {
92
+ console.log(`✓ SSH tunnel created: http://${sshTunnelUrl}\n`);
93
+
94
+ // Update config to use tunnel
95
+ const configPath = path.join(__dirname, 'config/local.json');
96
+ let config = {};
97
+
98
+ if (fs.existsSync(configPath)) {
99
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
100
+ }
101
+
102
+ if (!config.test) config.test = {};
103
+ if (!config.test.keycloak) config.test.keycloak = {};
104
+ config.test.keycloak.baseUrl = `http://${sshTunnelUrl}`;
105
+
106
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
107
+ console.log(`✓ Updated test/config/local.json to use SSH tunnel\n`);
108
+
109
+ // Clear propertiesmanager cache and retry
110
+ delete require.cache[require.resolve('propertiesmanager')];
111
+
112
+ // Retry setup
113
+ await enableServerFeatures();
114
+ sshTunnelProcess = sshTunnelUrl; // Mark that we created a tunnel
115
+ } else {
116
+ throw new Error('SSH tunnel creation returned null');
117
+ }
118
+ } catch (tunnelErr) {
119
+ console.error('\n✗ SSH tunnel connection failed:');
120
+ console.error(` Error: ${tunnelErr.message}`);
121
+ console.error('\nTroubleshooting steps:');
122
+ console.error(' 1. Verify SSH key exists: ls -la ~/.ssh/id_ed25519');
123
+ console.error(' 2. Verify SSH access: ssh -i ~/.ssh/id_ed25519 smart@smart-dell-sml.crs4.it echo OK');
124
+ console.error(' 3. Verify remote Keycloak is running on the server');
125
+ console.error(' 4. Try manual tunnel: ssh -L 127.0.0.1:9998:127.0.0.1:8080 smart@smart-dell-sml.crs4.it');
126
+ console.error('\nTest execution cancelled.\n');
127
+ throw new Error(`Failed to connect to Keycloak. Direct connection (127.0.0.1:9998) failed and SSH tunnel creation also failed: ${tunnelErr.message}`);
128
+ }
129
+ } else {
130
+ // Not a connection refused error - propagate as-is
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ console.log('\n=== Global setup complete ===\n');
136
+ });
137
+
138
+ // Global after hook - cleanup test realm and SSH tunnel
139
+ after(async function() {
140
+ this.timeout(60000);
141
+
142
+ console.log('\n=== Running global test teardown ===\n');
143
+
144
+ try {
145
+ // Load test config to get realm name
146
+ const { TEST_REALM } = require('./testConfig');
147
+
148
+ // Delete test realm to restore server to initial state
149
+ try {
150
+ console.log(`Deleting test realm: ${TEST_REALM}`);
151
+ await keycloakManager.realms.del({ realm: TEST_REALM });
152
+ console.log(`✓ Test realm deleted: ${TEST_REALM}`);
153
+ } catch (err) {
154
+ if (err.message && err.message.includes('404')) {
155
+ console.log(`⚠ Test realm already deleted or not found: ${TEST_REALM}`);
156
+ } else {
157
+ console.error(`⚠ Error deleting test realm: ${err.message}`);
158
+ }
159
+ }
160
+
161
+ // Stop Keycloak admin client to close connections
162
+ try {
163
+ keycloakManager.stop();
164
+ console.log('✓ Closed Keycloak admin client connection');
165
+ } catch (err) {
166
+ // Connection already closed or other state
167
+ console.log('✓ Keycloak admin client stopped');
168
+ }
169
+
170
+ // Clean up SSH tunnel config file if it was created
171
+ try {
172
+ const configPath = path.join(__dirname, 'config/local.json');
173
+ if (fs.existsSync(configPath)) {
174
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
175
+ // Only delete local.json if it contains SSH tunnel config
176
+ if (config.test?.keycloak?.baseUrl?.includes('127.0.0.1:9998')) {
177
+ fs.unlinkSync(configPath);
178
+ console.log('✓ Cleaned up SSH tunnel config (test/config/local.json)');
179
+ }
180
+ }
181
+ } catch (err) {
182
+ console.log('⚠ Could not cleanup config file:', err.message);
183
+ }
184
+
185
+ } catch (err) {
186
+ console.error('⚠ Error during test teardown:', err.message);
187
+ }
188
+
189
+ if (sshTunnelProcess) {
190
+ console.log('✓ SSH tunnel will close automatically');
191
+ }
192
+
193
+ console.log('\n=== Global test teardown complete ===\n');
194
+ });
@@ -0,0 +1,224 @@
1
+ const path = require('path');
2
+ const { expect } = require('chai');
3
+
4
+ process.env.NODE_ENV = process.env.NODE_ENV || 'test';
5
+ process.env.PROPERTIES_PATH = path.join(__dirname, '..', 'config');
6
+
7
+ const { conf } = require('propertiesmanager');
8
+ const keycloakManager = require('keycloak-api-manager');
9
+
10
+ function shouldSkipFeature(err) {
11
+ if (!err || !err.message) {
12
+ return false;
13
+ }
14
+ const text = err.message.toLowerCase();
15
+ return (
16
+ text.includes('not supported') ||
17
+ text.includes('unknown_error') ||
18
+ text.includes('http 404') ||
19
+ text.includes('http 400')
20
+ );
21
+ }
22
+
23
+ async function getFlowByAliasOrId(aliasOrId, candidateId) {
24
+ try {
25
+ return await keycloakManager.authenticationManagement.getFlow({ flowId: aliasOrId });
26
+ } catch (firstErr) {
27
+ if (candidateId) {
28
+ return await keycloakManager.authenticationManagement.getFlow({ flowId: candidateId });
29
+ }
30
+ throw firstErr;
31
+ }
32
+ }
33
+
34
+ async function deleteFlowByAliasOrId(aliasOrId, candidateId) {
35
+ try {
36
+ await keycloakManager.authenticationManagement.deleteFlow({ flowId: aliasOrId });
37
+ } catch (firstErr) {
38
+ if (candidateId) {
39
+ await keycloakManager.authenticationManagement.deleteFlow({ flowId: candidateId });
40
+ return;
41
+ }
42
+ throw firstErr;
43
+ }
44
+ }
45
+
46
+ describe('AuthenticationManagement Handler', function () {
47
+ this.timeout(60000);
48
+
49
+ const keycloakConfig = (conf && conf.keycloak) || {};
50
+ const testRealm = `auth-mgmt-realm-${Date.now()}`;
51
+ const customFlowAlias = `custom-flow-${Date.now()}`;
52
+ const copiedFlowAlias = `copied-flow-${Date.now()}`;
53
+
54
+ before(async function () {
55
+ await keycloakManager.configure({
56
+ baseUrl: keycloakConfig.baseUrl,
57
+ realmName: keycloakConfig.realmName,
58
+ clientId: keycloakConfig.clientId,
59
+ clientSecret: keycloakConfig.clientSecret,
60
+ username: keycloakConfig.username,
61
+ password: keycloakConfig.password,
62
+ grantType: keycloakConfig.grantType,
63
+ tokenLifeSpan: keycloakConfig.tokenLifeSpan,
64
+ scope: keycloakConfig.scope,
65
+ });
66
+
67
+ await keycloakManager.realms.create({ realm: testRealm, enabled: true });
68
+ keycloakManager.setConfig({ realmName: testRealm });
69
+ });
70
+
71
+ after(async function () {
72
+ try {
73
+ keycloakManager.setConfig({ realmName: testRealm });
74
+ } catch (err) {
75
+ // best effort
76
+ }
77
+
78
+ try {
79
+ await keycloakManager.authenticationManagement.deleteFlow({ flowId: copiedFlowAlias });
80
+ } catch (err) {
81
+ // best effort
82
+ }
83
+
84
+ try {
85
+ await keycloakManager.authenticationManagement.deleteFlow({ flowId: customFlowAlias });
86
+ } catch (err) {
87
+ // best effort
88
+ }
89
+
90
+ try {
91
+ await keycloakManager.realms.del({ realm: testRealm });
92
+ } catch (err) {
93
+ // best effort
94
+ }
95
+
96
+ if (typeof keycloakManager.stop === 'function') {
97
+ keycloakManager.stop();
98
+ }
99
+ });
100
+
101
+ it('should list required actions and provider metadata', async function () {
102
+ const requiredActions = await keycloakManager.authenticationManagement.getRequiredActions();
103
+ expect(requiredActions).to.be.an('array');
104
+
105
+ const unregistered = await keycloakManager.authenticationManagement.getUnregisteredRequiredActions();
106
+ expect(unregistered).to.be.an('array');
107
+
108
+ const updatePassword = await keycloakManager.authenticationManagement.getRequiredActionForAlias({
109
+ alias: 'UPDATE_PASSWORD',
110
+ });
111
+ expect(updatePassword).to.be.an('object');
112
+
113
+ const actionProviders = await keycloakManager.authenticationManagement.getFormActionProviders();
114
+ expect(actionProviders).to.be.an('array');
115
+
116
+ const authenticatorProviders = await keycloakManager.authenticationManagement.getAuthenticatorProviders();
117
+ expect(authenticatorProviders).to.be.an('array');
118
+
119
+ const clientAuthenticatorProviders = await keycloakManager.authenticationManagement.getClientAuthenticatorProviders();
120
+ expect(clientAuthenticatorProviders).to.be.an('array');
121
+
122
+ const formProviders = await keycloakManager.authenticationManagement.getFormProviders();
123
+ expect(formProviders).to.be.an('array');
124
+ });
125
+
126
+ it('should read and modify required action settings when available', async function () {
127
+ try {
128
+ const before = await keycloakManager.authenticationManagement.getRequiredActionForAlias({
129
+ alias: 'UPDATE_PASSWORD',
130
+ });
131
+
132
+ const description = await keycloakManager.authenticationManagement.getRequiredActionConfigDescription({
133
+ alias: 'UPDATE_PASSWORD',
134
+ });
135
+ expect(description).to.be.an('object');
136
+
137
+ await keycloakManager.authenticationManagement.updateRequiredAction(
138
+ { alias: 'UPDATE_PASSWORD' },
139
+ {
140
+ ...before,
141
+ enabled: true,
142
+ }
143
+ );
144
+
145
+ await keycloakManager.authenticationManagement.raiseRequiredActionPriority({
146
+ alias: 'UPDATE_PASSWORD',
147
+ });
148
+ await keycloakManager.authenticationManagement.lowerRequiredActionPriority({
149
+ alias: 'UPDATE_PASSWORD',
150
+ });
151
+ } catch (err) {
152
+ if (shouldSkipFeature(err)) {
153
+ this.skip();
154
+ return;
155
+ }
156
+ throw err;
157
+ }
158
+ });
159
+
160
+ it('should manage authentication flows and executions', async function () {
161
+ const flows = await keycloakManager.authenticationManagement.getFlows();
162
+ expect(flows).to.be.an('array');
163
+ expect(flows.length).to.be.greaterThan(0);
164
+
165
+ const browserFlow = flows.find((flow) => flow.alias === 'browser') || flows[0];
166
+ const flowDetails = await getFlowByAliasOrId(browserFlow.alias, browserFlow.id);
167
+ expect(flowDetails).to.be.an('object');
168
+
169
+ await keycloakManager.authenticationManagement.createFlow({
170
+ alias: customFlowAlias,
171
+ providerId: 'basic-flow',
172
+ topLevel: true,
173
+ builtIn: false,
174
+ description: 'Test custom flow',
175
+ });
176
+
177
+ const flowsAfterCreate = await keycloakManager.authenticationManagement.getFlows();
178
+ const customFlowMeta = flowsAfterCreate.find((flow) => flow.alias === customFlowAlias);
179
+ const customFlow = await getFlowByAliasOrId(
180
+ customFlowAlias,
181
+ customFlowMeta && customFlowMeta.id
182
+ );
183
+ expect(customFlow).to.be.an('object');
184
+ expect(customFlow.alias).to.equal(customFlowAlias);
185
+
186
+ await keycloakManager.authenticationManagement.copyFlow({
187
+ flow: customFlowAlias,
188
+ newName: copiedFlowAlias,
189
+ });
190
+
191
+ const flowsAfterCopy = await keycloakManager.authenticationManagement.getFlows();
192
+ const copiedFlowMeta = flowsAfterCopy.find((flow) => flow.alias === copiedFlowAlias);
193
+ const copiedFlow = await getFlowByAliasOrId(
194
+ copiedFlowAlias,
195
+ copiedFlowMeta && copiedFlowMeta.id
196
+ );
197
+ expect(copiedFlow).to.be.an('object');
198
+ expect(copiedFlow.alias).to.equal(copiedFlowAlias);
199
+
200
+ const executions = await keycloakManager.authenticationManagement.getExecutions({
201
+ flow: copiedFlowAlias,
202
+ });
203
+ expect(executions).to.be.an('array');
204
+
205
+ try {
206
+ await keycloakManager.authenticationManagement.addExecutionToFlow({
207
+ flow: copiedFlowAlias,
208
+ provider: 'auth-cookie',
209
+ });
210
+
211
+ const executionsAfter = await keycloakManager.authenticationManagement.getExecutions({
212
+ flow: copiedFlowAlias,
213
+ });
214
+ expect(executionsAfter).to.be.an('array');
215
+ } catch (err) {
216
+ if (!shouldSkipFeature(err)) {
217
+ throw err;
218
+ }
219
+ }
220
+
221
+ await deleteFlowByAliasOrId(copiedFlowAlias, copiedFlowMeta && copiedFlowMeta.id);
222
+ await deleteFlowByAliasOrId(customFlowAlias, customFlowMeta && customFlowMeta.id);
223
+ });
224
+ });
@@ -0,0 +1,76 @@
1
+ const path = require('path');
2
+ const { expect } = require('chai');
3
+
4
+ process.env.NODE_ENV = process.env.NODE_ENV || 'test';
5
+ process.env.PROPERTIES_PATH = path.join(__dirname, '..', 'config');
6
+
7
+ const keycloakManager = require('keycloak-api-manager');
8
+ const { KEYCLOAK_CONFIG, TEST_CLIENT_ID, TEST_REALM } = require('../testConfig');
9
+
10
+ function buildConfig(overrides = {}) {
11
+ return {
12
+ ...KEYCLOAK_CONFIG,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('Authentication - client_credentials grant', function () {
18
+ this.timeout(20000);
19
+
20
+ let testClientId = null;
21
+ let testClientSecret = null;
22
+
23
+ before(async function () {
24
+ const clients = await keycloakManager.clients.find({ clientId: TEST_CLIENT_ID });
25
+ const testClient = clients.find((client) => client.clientId === TEST_CLIENT_ID);
26
+
27
+ if (!testClient) {
28
+ this.skip();
29
+ return;
30
+ }
31
+
32
+ testClientId = testClient.clientId;
33
+ const secret = await keycloakManager.clients.getClientSecret({ id: testClient.id });
34
+ testClientSecret = secret?.value;
35
+
36
+ if (!testClientSecret) {
37
+ this.skip();
38
+ }
39
+ });
40
+
41
+ after(async function () {
42
+ if (!KEYCLOAK_CONFIG?.username || !KEYCLOAK_CONFIG?.password) {
43
+ return;
44
+ }
45
+
46
+ await keycloakManager.configure(
47
+ buildConfig({
48
+ grantType: KEYCLOAK_CONFIG.grantType || 'password',
49
+ tokenLifeSpan: KEYCLOAK_CONFIG.tokenLifeSpan || 60,
50
+ })
51
+ );
52
+ });
53
+
54
+ it('authenticates without refresh token errors', async function () {
55
+ if (!testClientId || !testClientSecret) {
56
+ this.skip();
57
+ return;
58
+ }
59
+
60
+ await keycloakManager.configure(
61
+ buildConfig({
62
+ realmName: TEST_REALM,
63
+ clientId: testClientId,
64
+ clientSecret: testClientSecret,
65
+ grantType: 'client_credentials',
66
+ tokenLifeSpan: 60,
67
+ username: undefined,
68
+ password: undefined,
69
+ })
70
+ );
71
+
72
+ const token = keycloakManager.getToken();
73
+ expect(token).to.have.property('accessToken');
74
+ expect(token.accessToken).to.be.a('string').and.to.have.length.greaterThan(0);
75
+ });
76
+ });