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 +13 -0
- package/package.json +1 -1
- package/services/ciscoSyncService.js +0 -0
- package/services/dynamics365Service.js +109 -0
- package/services/keycloak/README.md +13 -0
- package/services/keycloak/monitoring.js +122 -0
- package/services/keycloak/twofa.js +125 -0
- package/services/keycloak/utils.js +30 -0
- package/services/keycloakService.js +686 -510
- package/services/teamsService.js +4 -10
- package/.vscode/settings.json +0 -2
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
|
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
|
+
|