ef-keycloak-connect 1.8.4-patch-4.0 → 1.8.6-patch-1.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/README.md CHANGED
@@ -41,6 +41,7 @@ This adapter is extended from keycloak-connect and have functionalities of both
41
41
  - assignRoleToUser
42
42
  - authenticateFinesse
43
43
  - createRealmAsTenant
44
+ - dynamics365Sso
44
45
 
45
46
  ```
46
47
  ### Example
@@ -445,6 +446,9 @@ It takes 5 arguments:
445
446
  - finesse_server_url: The url of finesse server (In case of normal keycloak auth, send this parameter as **' '**)
446
447
  - user_roles: The array containing user_roles, it will be used to assign roles to finesse user while synching it with Keycloak (for normal auth send it as [ ]).
447
448
  - finesse_token: acess token for finesse SSO authentication (It will be passed if Finesse SSO instance is connected, in case of non SSO will pass empty string **' '** as argument)
449
+
450
+
451
+
448
452
 
449
453
  ##### Example of SSO Finesse Auth:
450
454
 
@@ -455,6 +459,15 @@ It takes 5 arguments:
455
459
 
456
460
  authenticateFinesse('johndoe', '12345', `https://${finesse_server_url}:${port}`, ['agent','supervisor'], '')
457
461
 
462
+ ### dynamics365Sso( userRoles, validationToken, dynamics365Url )
463
+
464
+ This function sync microsoft dynamics 365 user in keycloak, it first authenticates user from dynamics365, then check for its existance in keycloak. If it exists in keycloak then generates an access_token along with role mapping and team mapping and return it to user. If user doesn't exist then it creates a user, assign it roles and team and return the access_token along with role mapping/team mapping for newly created user.
465
+
466
+ It takes 3 arguments:
467
+ - userRoles: The array containing user roles, it will be used to assign roles to dynamics365 user while synching it with Keycloak e.g **['agent']**.
468
+ - validationToken: acess token for dynamics365 user validation and to get user details.
469
+ - dynamics365Url: The url of dynamics365 server e.g **'https://{fqdn}/api/data/v9.0'**
470
+
458
471
  ### generateAccessTokenFromRefreshToken(refreshToken)
459
472
 
460
473
  This function generates a new access_token by using the refreshToken received in parameter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ef-keycloak-connect",
3
- "version": "1.8.4-patch-4.0",
3
+ "version": "1.8.6-patch-1.0",
4
4
  "description": "Node JS keycloak adapter for authentication and authorization.",
5
5
  "main": "index.js",
6
6
  "scripts": {
File without changes
@@ -0,0 +1,109 @@
1
+ const parseXMLString = require( 'xml2js' ).parseString;
2
+ const https = require( 'https' );
3
+
4
+ let requestController = require( "../controller/requestController.js" );
5
+
6
+ class Dynamics365Service {
7
+
8
+
9
+ async authenticateUserViaDynamics365( validationToken, dynamics365Url ) {
10
+
11
+ return new Promise( async ( resolve, reject ) => {
12
+
13
+ let URL = dynamics365Url + '/WhoAmI'
14
+
15
+
16
+ let config = {
17
+ method: 'get',
18
+ 'Content-Type': 'application/json',
19
+ 'Accept': 'application/json',
20
+ 'OData-MaxVersion': '4.0',
21
+ 'OData-Version': '4.0',
22
+ url: URL,
23
+ headers: {
24
+ 'Authorization': `Bearer ${validationToken}`
25
+ },
26
+ //disable ssl
27
+ httpsAgent: new https.Agent( { rejectUnauthorized: false } )
28
+ };
29
+
30
+ try {
31
+
32
+ let whoAmIResponse = await requestController.httpRequest( config, false );
33
+
34
+ let userId = whoAmIResponse?.data?.UserId;
35
+
36
+ try {
37
+
38
+ let URL1 = `${dynamics365Url}/systemusers(${userId})?$select=fullname,firstname,middlename,lastname,internalemailaddress,title,isdisabled,businessunitid,mobilephone,createdon,modifiedon`
39
+ config.url = URL1;
40
+ config.maxBodyLength = 'Infinity';
41
+
42
+ let userObjectResponse = await requestController.httpRequest( config, true );
43
+ let userObject = userObjectResponse?.data;
44
+
45
+
46
+ resolve( {
47
+ 'data': userObject,
48
+ 'status': userObjectResponse?.status
49
+ } );
50
+
51
+ }
52
+ catch ( er ) {
53
+
54
+ if ( er.code == "ENOTFOUND" ) {
55
+
56
+ reject( {
57
+ error_message: "Dynamics365 Authentication Error: An error occurred while getting the user data from Dynamics365.",
58
+ error_detail: {
59
+ status: 408,
60
+ reason: `Dynamics365 server not accessible against URL: ${dynamics365Url}`
61
+ }
62
+ } )
63
+
64
+ } else if ( er.response ) {
65
+
66
+ reject( {
67
+ error_message: "Dynamics365 Authentication Error: An error occurred while getting the user data from Dynamics365.",
68
+ error_detail: {
69
+ status: er.response.status,
70
+ reason: er.response.statusText
71
+ }
72
+ } )
73
+
74
+ }
75
+
76
+ }
77
+
78
+ }
79
+ catch ( er ) {
80
+
81
+ if ( er.code == "ENOTFOUND" ) {
82
+
83
+ reject( {
84
+ error_message: "Dynamics365 Authentication Error: An error occurred while validating user in Dynamics365.",
85
+ error_detail: {
86
+ status: 408,
87
+ reason: `Finesse server not accessible against URL: ${dynamics365Url}`
88
+ }
89
+ } )
90
+
91
+ } else if ( er.response ) {
92
+
93
+ reject( {
94
+ error_message: "Dynamics365 Authentication Error: An error occurred while validating user in Dynamics365.",
95
+ error_detail: {
96
+ status: er.response.status,
97
+ reason: er.response.statusText
98
+ }
99
+ } )
100
+
101
+ }
102
+
103
+ }
104
+
105
+ } );
106
+ }
107
+ }
108
+
109
+ module.exports = Dynamics365Service;
@@ -0,0 +1,13 @@
1
+ Keycloak Service Modules
2
+
3
+ This folder holds cohesive helpers used by `services/keycloakService.js`.
4
+
5
+ - `utils.js`: Small validation helpers (e.g., `validateUser`, `isValidPhoneNumber`).
6
+ - `monitoring.js`: Polls Keycloak Admin Events to detect user creations; returns a `stop()` function.
7
+ - `twofa.js`: Implements 2FA helpers: QR code generation and SMS OTP via Twilio Verify.
8
+
9
+ Notes
10
+
11
+ - These modules are pure helpers; the public API remains on `KeycloakService` to preserve compatibility.
12
+ - Modules receive the `KeycloakService` instance when they need access to tokens, config, or Twilio client.
13
+
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Keycloak Admin Events monitoring utilities.
3
+ * Encapsulates internal state so multiple monitors don't interfere.
4
+ */
5
+
6
+ const requestController = require('../../controller/requestController');
7
+
8
+ function createState() {
9
+ return {
10
+ previousEvents: [],
11
+ isFirstRun: true,
12
+ };
13
+ }
14
+
15
+ function parseUserData(representation) {
16
+ try {
17
+ return JSON.parse(representation);
18
+ } catch (_) {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function computeNewEvents(state, events) {
24
+ const incoming = Array.isArray(events) ? events : [];
25
+ if (state.isFirstRun) {
26
+ state.previousEvents = incoming.slice().sort((a, b) => b.time - a.time);
27
+ state.isFirstRun = false;
28
+ return state.previousEvents;
29
+ }
30
+ if (incoming.length > 0 && state.previousEvents.length > 0) {
31
+ const lastStoredEventTime = state.previousEvents.reduce((max, e) => Math.max(max, e.time), 0);
32
+ const genuinelyNew = incoming.filter((e) => e.time > lastStoredEventTime);
33
+ if (genuinelyNew.length > 0) {
34
+ const sortedNew = genuinelyNew.slice().sort((a, b) => b.time - a.time);
35
+ state.previousEvents = sortedNew;
36
+ return sortedNew;
37
+ }
38
+ }
39
+ return [];
40
+ }
41
+
42
+ async function fetchAdminEvents(baseUrl, realm, adminToken) {
43
+ const url = `${baseUrl}admin/realms/${realm}/admin-events`;
44
+ const config = {
45
+ method: 'get',
46
+ url,
47
+ headers: {
48
+ Authorization: `Bearer ${adminToken}`,
49
+ Accept: 'application/json',
50
+ 'cache-control': 'no-cache',
51
+ },
52
+ params: {
53
+ operationTypes: 'CREATE',
54
+ resourceTypes: 'USER',
55
+ },
56
+ };
57
+
58
+ const response = await requestController.httpRequest(config, false);
59
+ return response.data;
60
+ }
61
+
62
+ /**
63
+ * Start polling admin events; returns a function to stop.
64
+ * @param {object} svc - Instance of KeycloakService
65
+ * @param {{pollingInterval?:number}} opts
66
+ * @param {(evt:any)=>void} callback
67
+ */
68
+ async function startUserMonitoring(svc, { pollingInterval }, callback) {
69
+ if (!svc.keycloakConfig['auth-server-url'] || !svc.keycloakConfig['realm']) {
70
+ throw {
71
+ error_message: 'Configuration Error: baseUrl and realm are required in config.',
72
+ error_detail: 'Missing required configuration parameters',
73
+ };
74
+ }
75
+
76
+ const state = createState();
77
+ const intervalMs = pollingInterval || 5000;
78
+
79
+ const timer = setInterval(async () => {
80
+ try {
81
+ const adminTok = await svc.getAccessToken(
82
+ svc.keycloakConfig['USERNAME_ADMIN'],
83
+ svc.keycloakConfig['PASSWORD_ADMIN']
84
+ );
85
+ const events = await fetchAdminEvents(
86
+ svc.keycloakConfig['auth-server-url'],
87
+ svc.keycloakConfig['realm'],
88
+ adminTok.access_token
89
+ );
90
+ const newEvents = computeNewEvents(state, events);
91
+ newEvents.forEach((event) => {
92
+ const userData = parseUserData(event.representation);
93
+ if (userData) {
94
+ callback({
95
+ time: event.time,
96
+ realmId: event.realmId,
97
+ authDetails: event.authDetails,
98
+ operationType: event.operationType,
99
+ resourceType: event.resourceType,
100
+ resourcePath: event.resourcePath,
101
+ representation: userData,
102
+ });
103
+ }
104
+ });
105
+ } catch (err) {
106
+ // Swallow errors during polling to keep monitor running
107
+ // eslint-disable-next-line no-console
108
+ console.error('Error in monitoring loop:', err);
109
+ }
110
+ }, intervalMs);
111
+
112
+ return () => {
113
+ clearInterval(timer);
114
+ return { message: 'Monitoring stopped successfully' };
115
+ };
116
+ }
117
+
118
+ module.exports = {
119
+ startUserMonitoring,
120
+ parseUserData,
121
+ };
122
+
@@ -0,0 +1,125 @@
1
+ const qrcode = require('qrcode');
2
+ const speakeasy = require('speakeasy');
3
+
4
+ /**
5
+ * Generate OTP secret and QR image for a username
6
+ */
7
+ async function getQRCode(username) {
8
+ const secret = speakeasy.generateSecret({ name: username, symbols: false });
9
+ let image;
10
+ try {
11
+ image = await qrcode.toDataURL(secret.otpauth_url + '&issuer=EFCX');
12
+ } catch (_) {
13
+ return false;
14
+ }
15
+ return { secret: secret.base32, image };
16
+ }
17
+
18
+ /**
19
+ * Update Keycloak user attributes (thin wrapper around svc.updateUserAttributes)
20
+ */
21
+ async function updateUserAttributes(svc, adminToken, userId, attributesToUpdate) {
22
+ return svc.updateUserAttributes(adminToken, userId, attributesToUpdate);
23
+ }
24
+
25
+ /**
26
+ * Register/Bind phone number and send OTP via SMS
27
+ */
28
+ async function registerPhoneNumber(svc, username, phoneNumber) {
29
+ if (!svc.isValidPhoneNumber(phoneNumber)) {
30
+ return Promise.reject({ error: 400, error_message: 'Invalid phone number' });
31
+ }
32
+
33
+ const userObjectToBeReturned = { username };
34
+
35
+ const adminData = await svc.getAccessToken(
36
+ svc.keycloakConfig.USERNAME_ADMIN,
37
+ svc.keycloakConfig.PASSWORD_ADMIN
38
+ );
39
+ const adminToken = adminData.access_token;
40
+
41
+ const userObject = await svc.getUserDetails(adminToken, username);
42
+ if (!userObject.attributes) {
43
+ userObject.attributes = {};
44
+ }
45
+
46
+ if (userObject.attributes) {
47
+ const userObjectAttributes = userObject.attributes;
48
+ const newAttributes = {};
49
+ if (Object.keys(userObjectAttributes).length > 0) {
50
+ for (const key in userObjectAttributes) {
51
+ newAttributes[key] = userObjectAttributes[key][0];
52
+ }
53
+ }
54
+ newAttributes.is2FARegistered = false;
55
+ newAttributes.twoFAChannel = 'sms';
56
+ newAttributes.phoneNumber = '+' + phoneNumber;
57
+
58
+ try {
59
+ await svc.updateUserAttributes(adminToken, userObject.id, newAttributes);
60
+ await svc.sendOTPviaSMS('+' + phoneNumber);
61
+ } catch (error) {
62
+ const err = await svc.errorService.handleError(error);
63
+ return Promise.reject({
64
+ error_message: 'Error occurred while registering phone number.',
65
+ error_detail: err,
66
+ });
67
+ }
68
+ } else {
69
+ return Promise.reject({ error: 400, error_message: 'Error occurred while fetching user attributes.' });
70
+ }
71
+
72
+ userObjectToBeReturned.is2FARegistered = false;
73
+ userObjectToBeReturned.twoFAChannel = 'sms';
74
+ userObjectToBeReturned.phoneNumber = '+' + phoneNumber;
75
+ userObjectToBeReturned.message = 'OTP required';
76
+
77
+ return userObjectToBeReturned;
78
+ }
79
+
80
+ /**
81
+ * Send OTP via Twilio Verify
82
+ */
83
+ async function sendOTPviaSMS(svc, phoneNumber) {
84
+ if (!svc.twilioClient || !svc.keycloakConfig.TWILIO_VERIFY_SID) {
85
+ return Promise.reject({
86
+ error: 500,
87
+ error_message: 'Twilio is not configured. Please set TWILIO_SID, TWILIO_AUTH_TOKEN and TWILIO_VERIFY_SID.',
88
+ });
89
+ }
90
+
91
+ if (phoneNumber.startsWith('+')) {
92
+ phoneNumber = phoneNumber.slice(1);
93
+ if (!svc.isValidPhoneNumber(phoneNumber)) {
94
+ return Promise.reject({ error: 400, error_message: 'Invalid phone number' });
95
+ }
96
+ phoneNumber = '+' + phoneNumber;
97
+ } else {
98
+ if (!svc.isValidPhoneNumber(phoneNumber)) {
99
+ return Promise.reject({ error: 400, error_message: 'Invalid phone number' });
100
+ }
101
+ phoneNumber = '+' + phoneNumber;
102
+ }
103
+
104
+ try {
105
+ await svc.twilioClient.verify.v2
106
+ .services(svc.keycloakConfig.TWILIO_VERIFY_SID)
107
+ .verifications.create({ to: phoneNumber, channel: 'sms' });
108
+ } catch (_) {
109
+ return Promise.reject({
110
+ error: 400,
111
+ error_message:
112
+ 'Error occured while sending OTP via SMS. This may be because of some issue with Twilio Service.',
113
+ });
114
+ }
115
+
116
+ return 'OTP sent successfully.';
117
+ }
118
+
119
+ module.exports = {
120
+ getQRCode,
121
+ updateUserAttributes,
122
+ registerPhoneNumber,
123
+ sendOTPviaSMS,
124
+ };
125
+
@@ -0,0 +1,30 @@
1
+ const Joi = require('joi');
2
+
3
+ /**
4
+ * Validate EF user payload for authentication flows
5
+ * @param {{username:string,password:string,token:string,userRoles?:string[]}} userData
6
+ * @returns {{error?:any,value?:any}}
7
+ */
8
+ function validateUser(userData) {
9
+ const schema = Joi.object({
10
+ username: Joi.string().min(1).max(255).required(),
11
+ password: Joi.string().min(1).max(255).required(),
12
+ token: Joi.string().required(),
13
+ userRoles: Joi.array().items(Joi.string()).allow(null),
14
+ });
15
+ return schema.validate(userData);
16
+ }
17
+
18
+ /**
19
+ * Basic E.164-ish numeric validation (7–15 digits)
20
+ * Accepts only digits; callers should handle leading '+' separately
21
+ */
22
+ function isValidPhoneNumber(phoneNumber) {
23
+ return /^\d{7,15}$/.test(phoneNumber);
24
+ }
25
+
26
+ module.exports = {
27
+ validateUser,
28
+ isValidPhoneNumber
29
+ };
30
+