scimgateway 4.2.16 → 4.3.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 +67 -51
- package/config/{plugin-azure-ad.json → plugin-entra-id.json} +10 -7
- package/config/plugin-loki.json +7 -3
- package/config/plugin-scim.json +9 -5
- package/index.js +1 -1
- package/lib/{plugin-azure-ad.js → plugin-entra-id.js} +243 -171
- package/lib/plugin-ldap.js +16 -7
- package/lib/plugin-loki.js +77 -38
- package/lib/plugin-scim.js +166 -12
- package/lib/postinstall.js +2 -2
- package/lib/scimgateway.js +75 -85
- package/lib/utils.js +19 -5
- package/package.json +10 -11
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// =====================================================================================================================
|
|
2
|
-
// File: plugin-
|
|
2
|
+
// File: plugin-entra-id.js
|
|
3
3
|
//
|
|
4
4
|
// Author: Jarle Elshaug
|
|
5
5
|
//
|
|
6
|
-
// Purpose:
|
|
6
|
+
// Purpose: Entra ID provisioning including licenses e.g. O365
|
|
7
7
|
//
|
|
8
|
-
// Prereq:
|
|
8
|
+
// Prereq: Entra ID configuration:
|
|
9
9
|
// Application key defined (clientsecret)
|
|
10
10
|
// plugin-azure-ad.json configured with corresponding clientid and clientsecret
|
|
11
|
-
// Application permission
|
|
12
|
-
// Application must be member of "User Account Administrator"
|
|
11
|
+
// Application permission: Directory.ReadWriteAll and Organization.ReadWrite.All
|
|
12
|
+
// Application must be member of "User Account Administrator" or "Global administrator"
|
|
13
13
|
//
|
|
14
|
-
// Notes: For CA Provisioning - Use ConnectorXpress, import metafile
|
|
14
|
+
// Notes: For Symantec/Broadcom/CA Provisioning - Use ConnectorXpress, import metafile
|
|
15
15
|
// "node_modules\scimgateway\resources\Azure - ScimGateway.xml" for creating endpoint
|
|
16
16
|
//
|
|
17
17
|
// Using "Custom SCIM" attributes defined in scimgateway.endpointMap
|
|
@@ -129,7 +129,9 @@ for (const key in config.map.user) { // userAttributes = ['country', 'preferredL
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
for (const baseEntity in config.entity) { // ensure we have baseUrls in config and for this azure-ad plugin we overwrite any existing to hardcoded value ['https://graph.microsoft.com/beta']
|
|
132
|
-
config.entity[baseEntity].
|
|
132
|
+
if (config.entity[baseEntity].oauth && config.entity[baseEntity].oauth.tenantIdGUID) {
|
|
133
|
+
config.entity[baseEntity].baseUrls = ['https://graph.microsoft.com/beta'] // beta instead of 'v1.0' gives all user attributes when no $select
|
|
134
|
+
}
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
const _serviceClient = {}
|
|
@@ -914,10 +916,104 @@ scimgateway.modifyServicePlan = async (baseEntity, id, ctx) => {
|
|
|
914
916
|
throw new Error(`${action} error: ${action} is not supported`)
|
|
915
917
|
}
|
|
916
918
|
|
|
919
|
+
// =================================================
|
|
920
|
+
// getUser
|
|
921
|
+
// addOn helper for plugin-azure-ad
|
|
922
|
+
// =================================================
|
|
923
|
+
const getUser = async (baseEntity, uid, attributes, ctx) => { // uid = id, userName (upn) or externalId (upn)
|
|
924
|
+
if (attributes.length < 1) {
|
|
925
|
+
attributes = userAttributes
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const user = () => {
|
|
929
|
+
return new Promise((resolve, reject) => {
|
|
930
|
+
(async () => {
|
|
931
|
+
// const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
|
|
932
|
+
const method = 'GET'
|
|
933
|
+
const path = `/users/${querystring.escape(uid)}?$expand=manager($select=userPrincipalName)` // beta returns all attributes or use: ?$select=${attrs.join()}
|
|
934
|
+
const body = null
|
|
935
|
+
try {
|
|
936
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
937
|
+
const userObj = response.body
|
|
938
|
+
if (!userObj) {
|
|
939
|
+
const err = new Error('Got empty response when retrieving data for ' + uid)
|
|
940
|
+
return reject(err)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
let managerId
|
|
944
|
+
if (userObj.manager && userObj.manager.userPrincipalName) managerId = userObj.manager.userPrincipalName
|
|
945
|
+
delete userObj.manager
|
|
946
|
+
if (managerId) userObj.manager = managerId
|
|
947
|
+
|
|
948
|
+
resolve(userObj)
|
|
949
|
+
} catch (err) {
|
|
950
|
+
return reject(err)
|
|
951
|
+
}
|
|
952
|
+
})()
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const license = () => {
|
|
957
|
+
return new Promise((resolve, reject) => {
|
|
958
|
+
(async () => {
|
|
959
|
+
if (!attributes.includes('servicePlan.value')) return resolve(null) // licenses not requested
|
|
960
|
+
const method = 'GET'
|
|
961
|
+
const path = `/users/${querystring.escape(uid)}/licenseDetails`
|
|
962
|
+
const body = null
|
|
963
|
+
const retObj = { servicePlan: [] }
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
967
|
+
if (!response.body.value) {
|
|
968
|
+
const err = new Error('No content for license information ' + uid)
|
|
969
|
+
return reject(err)
|
|
970
|
+
} else {
|
|
971
|
+
if (response.body.value.length < 1) return resolve(null) // User with no licenses
|
|
972
|
+
for (let i = 0; i < response.body.value.length; i++) {
|
|
973
|
+
const skuPartNumber = response.body.value[i].skuPartNumber
|
|
974
|
+
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
|
|
975
|
+
if (response.body.value[i].servicePlans[index].provisioningStatus === 'Success' ||
|
|
976
|
+
response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput') {
|
|
977
|
+
const servicePlan = { value: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}` }
|
|
978
|
+
retObj.servicePlan.push(servicePlan)
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
resolve(retObj)
|
|
984
|
+
} catch (err) {
|
|
985
|
+
let statusCode
|
|
986
|
+
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
|
|
987
|
+
if (statusCode === 404) return resolve(null) // user have no plans
|
|
988
|
+
return reject(err)
|
|
989
|
+
}
|
|
990
|
+
})()
|
|
991
|
+
})
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// return Promise.all([user(), manager(), license()])
|
|
995
|
+
return Promise.all([user(), license()])
|
|
996
|
+
.then((results) => {
|
|
997
|
+
let retObj = {}
|
|
998
|
+
for (const i in results) { // merge async.parallell results to one
|
|
999
|
+
retObj = Object.assign(retObj, results[i])
|
|
1000
|
+
}
|
|
1001
|
+
return retObj
|
|
1002
|
+
})
|
|
1003
|
+
.catch((err) => {
|
|
1004
|
+
if (err.message.includes('empty response')) return null // no user found
|
|
1005
|
+
else throw (err)
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
|
|
917
1009
|
// =================================================
|
|
918
1010
|
// helpers
|
|
919
1011
|
// =================================================
|
|
920
1012
|
|
|
1013
|
+
//
|
|
1014
|
+
// start - REST endpoint template
|
|
1015
|
+
//
|
|
1016
|
+
|
|
921
1017
|
const getClientIdentifier = (ctx) => {
|
|
922
1018
|
if (!ctx?.request?.header?.authorization) return undefined
|
|
923
1019
|
const [user, secret] = getCtxAuth(ctx)
|
|
@@ -936,6 +1032,85 @@ const getCtxAuth = (ctx) => {
|
|
|
936
1032
|
else return [undefined, authToken] // bearer auth
|
|
937
1033
|
}
|
|
938
1034
|
|
|
1035
|
+
//
|
|
1036
|
+
// getAccessToken - returns oauth accesstoken
|
|
1037
|
+
//
|
|
1038
|
+
const getAccessToken = async (baseEntity, ctx) => {
|
|
1039
|
+
await lock.acquire()
|
|
1040
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
1041
|
+
const d = new Date() / 1000 // seconds (unix time)
|
|
1042
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier] && _serviceClient[baseEntity][clientIdentifier].accessToken &&
|
|
1043
|
+
(_serviceClient[baseEntity][clientIdentifier].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
|
|
1044
|
+
lock.release()
|
|
1045
|
+
return _serviceClient[baseEntity][clientIdentifier].accessToken
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const action = 'getAccessToken'
|
|
1049
|
+
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Retrieving accesstoken`)
|
|
1050
|
+
|
|
1051
|
+
const method = 'POST'
|
|
1052
|
+
const [, secret] = getCtxAuth(ctx) // if Auth PassTrough, secret from basic or bearer auth
|
|
1053
|
+
let tokenUrl
|
|
1054
|
+
let form
|
|
1055
|
+
|
|
1056
|
+
if (config.entity[baseEntity].oauth) {
|
|
1057
|
+
let resource
|
|
1058
|
+
try {
|
|
1059
|
+
const urlObj = new URL(config.entity[baseEntity].baseUrls[0])
|
|
1060
|
+
resource = urlObj.origin
|
|
1061
|
+
} catch (err) {
|
|
1062
|
+
resource = null
|
|
1063
|
+
}
|
|
1064
|
+
if (config.entity[baseEntity].oauth.tenantIdGUID) { // Azure
|
|
1065
|
+
tokenUrl = `https://login.microsoftonline.com/${config.entity[baseEntity].oauth.tenantIdGUID}/oauth2/token`
|
|
1066
|
+
} else {
|
|
1067
|
+
tokenUrl = `https://login.microsoftonline.com/${config.entity[baseEntity].oauth.tokenUrl}`
|
|
1068
|
+
}
|
|
1069
|
+
form = {
|
|
1070
|
+
grant_type: 'client_credentials',
|
|
1071
|
+
client_id: config.entity[baseEntity].oauth.clientId,
|
|
1072
|
+
client_secret: secret || scimgateway.getPassword(`endpoint.entity.${baseEntity}.oauth.clientSecret`, configFile), // using config if no Auth PassThrough
|
|
1073
|
+
resource: resource // "https://graph.microsoft.com"
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
const err = new Error(`[${action}] missing supported endpoint authentication configuration`)
|
|
1077
|
+
lock.release()
|
|
1078
|
+
throw (err)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const options = {
|
|
1082
|
+
headers: {
|
|
1083
|
+
'Content-Type': 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
const response = await doRequest(baseEntity, method, tokenUrl, form, ctx, options)
|
|
1089
|
+
if (!response.body) {
|
|
1090
|
+
const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
|
|
1091
|
+
throw (err)
|
|
1092
|
+
}
|
|
1093
|
+
const jbody = response.body
|
|
1094
|
+
if (jbody.error) {
|
|
1095
|
+
const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
|
|
1096
|
+
throw (err)
|
|
1097
|
+
} else if (!jbody.access_token || !jbody.expires_in) {
|
|
1098
|
+
const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
|
|
1099
|
+
throw (err)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const d = new Date() / 1000 // seconds (unix time)
|
|
1103
|
+
jbody.validTo = d + parseInt(jbody.expires_in) // instead of using expires_on (clock may not be in sync with NTP, AAD default expires_in = 3600 seconds)
|
|
1104
|
+
scimgateway.logger.silly(`${pluginName}[${baseEntity}] ${action}: AccessToken = ${jbody.access_token}`)
|
|
1105
|
+
|
|
1106
|
+
lock.release()
|
|
1107
|
+
return jbody
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
lock.release()
|
|
1110
|
+
throw (err)
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
939
1114
|
//
|
|
940
1115
|
// getServiceClient - returns options needed for connection parameters
|
|
941
1116
|
//
|
|
@@ -948,6 +1123,11 @@ const getCtxAuth = (ctx) => {
|
|
|
948
1123
|
const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
949
1124
|
const action = 'getServiceClient'
|
|
950
1125
|
|
|
1126
|
+
let authType
|
|
1127
|
+
if (config.entity[baseEntity].basicAuth) authType = 'basicAuth'
|
|
1128
|
+
else if (config.entity[baseEntity].oauth) authType = 'oauth'
|
|
1129
|
+
else if (config.entity[baseEntity].bearerAuth) authType = 'bearerAuth'
|
|
1130
|
+
|
|
951
1131
|
let urlObj
|
|
952
1132
|
if (!path) path = ''
|
|
953
1133
|
try {
|
|
@@ -958,20 +1138,22 @@ const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
|
958
1138
|
//
|
|
959
1139
|
|
|
960
1140
|
const clientIdentifier = getClientIdentifier(ctx)
|
|
961
|
-
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier]
|
|
1141
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier]) { // serviceClient already exist - Azure plugin specific
|
|
962
1142
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1143
|
+
if (_serviceClient[baseEntity][clientIdentifier].accessToken) {
|
|
1144
|
+
// check if token refresh is needed when using oauth
|
|
1145
|
+
const d = new Date() / 1000 // seconds (unix time)
|
|
1146
|
+
if (_serviceClient[baseEntity][clientIdentifier].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
|
|
1147
|
+
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Accesstoken about to expire in ${_serviceClient[baseEntity][clientIdentifier].accessToken.validTo - d} seconds`)
|
|
1148
|
+
try {
|
|
1149
|
+
const accessToken = await getAccessToken(baseEntity, ctx)
|
|
1150
|
+
_serviceClient[baseEntity][clientIdentifier].accessToken = accessToken
|
|
1151
|
+
_serviceClient[baseEntity][clientIdentifier].options.headers.Authorization = ` Bearer ${accessToken.access_token}`
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
delete _serviceClient[baseEntity][clientIdentifier]
|
|
1154
|
+
const newErr = err
|
|
1155
|
+
throw newErr
|
|
1156
|
+
}
|
|
975
1157
|
}
|
|
976
1158
|
}
|
|
977
1159
|
} else {
|
|
@@ -982,19 +1164,18 @@ const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
|
982
1164
|
const err = new Error(`Base URL have baseEntity=${baseEntity}, and configuration file ${pluginName}.json is missing required baseEntity configuration for ${baseEntity}`)
|
|
983
1165
|
throw err
|
|
984
1166
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1167
|
+
if (!config.entity[baseEntity].baseUrls || !Array.isArray(config.entity[baseEntity].baseUrls) || config.entity[baseEntity].baseUrls.length < 1) {
|
|
1168
|
+
const err = new Error(`missing configuration entity.${baseEntity}.baseUrls`)
|
|
1169
|
+
throw err
|
|
1170
|
+
}
|
|
989
1171
|
urlObj = new URL(config.entity[baseEntity].baseUrls[0])
|
|
990
1172
|
const param = {
|
|
991
1173
|
baseUrl: config.entity[baseEntity].baseUrls[0],
|
|
992
|
-
accessToken: accessToken, // Azure plugin specific
|
|
993
1174
|
options: {
|
|
994
1175
|
json: true, // json-object response instead of string
|
|
995
1176
|
headers: {
|
|
996
1177
|
'Content-Type': 'application/json',
|
|
997
|
-
|
|
1178
|
+
Accept: 'application/json'
|
|
998
1179
|
},
|
|
999
1180
|
host: urlObj.hostname,
|
|
1000
1181
|
port: urlObj.port, // null if https and 443 defined in url
|
|
@@ -1003,6 +1184,37 @@ const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
|
1003
1184
|
}
|
|
1004
1185
|
}
|
|
1005
1186
|
|
|
1187
|
+
if (ctx?.request?.header?.authorization) { // Auth PassThrough using ctx header
|
|
1188
|
+
param.options.headers.Authorization = ctx.request.header.authorization
|
|
1189
|
+
} else {
|
|
1190
|
+
switch (authType) {
|
|
1191
|
+
case 'basicAuth':
|
|
1192
|
+
if (!config.entity[baseEntity].basicAuth.username || !config.entity[baseEntity].basicAuth.password) {
|
|
1193
|
+
const err = new Error(`missing configuration entity.${baseEntity}.basicAuth.username/password`)
|
|
1194
|
+
throw err
|
|
1195
|
+
}
|
|
1196
|
+
param.options.headers.Authorization = 'Basic ' + Buffer.from(`${config.entity[baseEntity].basicAuth.username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.basicAuth.password`, configFile)}`).toString('base64')
|
|
1197
|
+
break
|
|
1198
|
+
case 'oauth':
|
|
1199
|
+
if (!config.entity[baseEntity].oauth.clientId || !config.entity[baseEntity].oauth.clientSecret) {
|
|
1200
|
+
const err = new Error(`missing configuration entity.${baseEntity}.oauth.clientId/clientSecret`)
|
|
1201
|
+
throw err
|
|
1202
|
+
}
|
|
1203
|
+
param.accessToken = await getAccessToken(baseEntity, ctx)
|
|
1204
|
+
param.options.headers.Authorization = `Bearer ${param.accessToken.access_token}`
|
|
1205
|
+
break
|
|
1206
|
+
case 'bearerAuth':
|
|
1207
|
+
if (!config.entity[baseEntity].bearerAuth.token) {
|
|
1208
|
+
const err = new Error(`missing configuration entity.${baseEntity}.bearerAuth.token`)
|
|
1209
|
+
throw err
|
|
1210
|
+
}
|
|
1211
|
+
param.options.headers.Authorization = 'Bearer ' + Buffer.from(`${scimgateway.getPassword(`endpoint.entity.${baseEntity}.bearerAuth.token`, configFile)}`).toString('base64')
|
|
1212
|
+
break
|
|
1213
|
+
default:
|
|
1214
|
+
// no auth
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1006
1218
|
// proxy
|
|
1007
1219
|
if (config.entity[baseEntity].proxy && config.entity[baseEntity].proxy.host) {
|
|
1008
1220
|
const agent = new HttpsProxyAgent(config.entity[baseEntity].proxy.host)
|
|
@@ -1012,12 +1224,14 @@ const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
|
1012
1224
|
}
|
|
1013
1225
|
}
|
|
1014
1226
|
|
|
1227
|
+
// config options
|
|
1228
|
+
if (config.entity[baseEntity].options) param.options = scimgateway.extendObj(param.options, config.entity[baseEntity].options)
|
|
1229
|
+
|
|
1015
1230
|
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
|
|
1016
1231
|
if (!_serviceClient[baseEntity][clientIdentifier]) _serviceClient[baseEntity][clientIdentifier] = {}
|
|
1017
|
-
|
|
1018
1232
|
_serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
|
|
1019
1233
|
|
|
1020
|
-
//
|
|
1234
|
+
// OData support - note, not using [clientIdentifier]
|
|
1021
1235
|
_serviceClient[baseEntity].nextLink = {}
|
|
1022
1236
|
_serviceClient[baseEntity].nextLink.users = null // Azure users pagination
|
|
1023
1237
|
_serviceClient[baseEntity].nextLink.groups = null // Azure groups pagination
|
|
@@ -1173,150 +1387,8 @@ const doRequest = async (baseEntity, method, path, body, ctx, opt, retryCount) =
|
|
|
1173
1387
|
} // doRequest
|
|
1174
1388
|
|
|
1175
1389
|
//
|
|
1176
|
-
//
|
|
1390
|
+
// end - REST endpoint template
|
|
1177
1391
|
//
|
|
1178
|
-
const getAccessToken = async (baseEntity, ctx) => {
|
|
1179
|
-
await lock.acquire()
|
|
1180
|
-
const clientIdentifier = getClientIdentifier(ctx)
|
|
1181
|
-
const d = new Date() / 1000 // seconds (unix time)
|
|
1182
|
-
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier] && _serviceClient[baseEntity][clientIdentifier].accessToken &&
|
|
1183
|
-
(_serviceClient[baseEntity][clientIdentifier].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
|
|
1184
|
-
lock.release()
|
|
1185
|
-
return _serviceClient[baseEntity][clientIdentifier].accessToken
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
const action = 'getAccessToken'
|
|
1189
|
-
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Retrieving accesstoken`)
|
|
1190
|
-
|
|
1191
|
-
const req = `https://login.microsoftonline.com/${config.entity[baseEntity].tenantIdGUID}/oauth2/token`
|
|
1192
|
-
const method = 'POST'
|
|
1193
|
-
|
|
1194
|
-
const [, secret] = getCtxAuth(ctx) // if Auth PassTrough, secret from basic or bearer auth
|
|
1195
|
-
const form = { // query string formatted
|
|
1196
|
-
grant_type: 'client_credentials',
|
|
1197
|
-
client_id: config.entity[baseEntity].clientId,
|
|
1198
|
-
client_secret: secret || scimgateway.getPassword(`endpoint.entity.${baseEntity}.clientSecret`, configFile), // using config if no Auth PassThrough
|
|
1199
|
-
resource: 'https://graph.microsoft.com'
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
const options = {
|
|
1203
|
-
headers: {
|
|
1204
|
-
'Content-Type': 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
try {
|
|
1209
|
-
const response = await doRequest(baseEntity, method, req, form, ctx, options)
|
|
1210
|
-
if (!response.body) {
|
|
1211
|
-
const err = new Error(`[${action}] No data retrieved from: ${method} ${req}`)
|
|
1212
|
-
throw (err)
|
|
1213
|
-
}
|
|
1214
|
-
const jbody = response.body
|
|
1215
|
-
if (jbody.error) {
|
|
1216
|
-
const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
|
|
1217
|
-
throw (err)
|
|
1218
|
-
} else if (!jbody.access_token || !jbody.expires_in) {
|
|
1219
|
-
const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
|
|
1220
|
-
throw (err)
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
const d = new Date() / 1000 // seconds (unix time)
|
|
1224
|
-
jbody.validTo = d + parseInt(jbody.expires_in) // instead of using expires_on (clock may not be in sync with NTP, AAD default expires_in = 3600 seconds)
|
|
1225
|
-
scimgateway.logger.silly(`${pluginName}[${baseEntity}] ${action}: AccessToken = ${jbody.access_token}`)
|
|
1226
|
-
|
|
1227
|
-
lock.release()
|
|
1228
|
-
return jbody
|
|
1229
|
-
} catch (err) {
|
|
1230
|
-
lock.release()
|
|
1231
|
-
throw (err)
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
const getUser = async (baseEntity, uid, attributes, ctx) => { // uid = id, userName (upn) or externalId (upn)
|
|
1236
|
-
if (attributes.length < 1) {
|
|
1237
|
-
attributes = userAttributes
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
const user = () => {
|
|
1241
|
-
return new Promise((resolve, reject) => {
|
|
1242
|
-
(async () => {
|
|
1243
|
-
// const [attrs] = scimgateway.endpointMapper('outbound', attributes, config.map.user) // SCIM/CustomSCIM => endpoint attribute standard
|
|
1244
|
-
const method = 'GET'
|
|
1245
|
-
const path = `/users/${querystring.escape(uid)}?$expand=manager($select=userPrincipalName)` // beta returns all attributes or use: ?$select=${attrs.join()}
|
|
1246
|
-
const body = null
|
|
1247
|
-
try {
|
|
1248
|
-
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
1249
|
-
const userObj = response.body
|
|
1250
|
-
if (!userObj) {
|
|
1251
|
-
const err = new Error('Got empty response when retrieving data for ' + uid)
|
|
1252
|
-
return reject(err)
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
let managerId
|
|
1256
|
-
if (userObj.manager && userObj.manager.userPrincipalName) managerId = userObj.manager.userPrincipalName
|
|
1257
|
-
delete userObj.manager
|
|
1258
|
-
if (managerId) userObj.manager = managerId
|
|
1259
|
-
|
|
1260
|
-
resolve(userObj)
|
|
1261
|
-
} catch (err) {
|
|
1262
|
-
return reject(err)
|
|
1263
|
-
}
|
|
1264
|
-
})()
|
|
1265
|
-
})
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
const license = () => {
|
|
1269
|
-
return new Promise((resolve, reject) => {
|
|
1270
|
-
(async () => {
|
|
1271
|
-
if (!attributes.includes('servicePlan.value')) return resolve(null) // licenses not requested
|
|
1272
|
-
const method = 'GET'
|
|
1273
|
-
const path = `/users/${querystring.escape(uid)}/licenseDetails`
|
|
1274
|
-
const body = null
|
|
1275
|
-
const retObj = { servicePlan: [] }
|
|
1276
|
-
|
|
1277
|
-
try {
|
|
1278
|
-
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
1279
|
-
if (!response.body.value) {
|
|
1280
|
-
const err = new Error('No content for license information ' + uid)
|
|
1281
|
-
return reject(err)
|
|
1282
|
-
} else {
|
|
1283
|
-
if (response.body.value.length < 1) return resolve(null) // User with no licenses
|
|
1284
|
-
for (let i = 0; i < response.body.value.length; i++) {
|
|
1285
|
-
const skuPartNumber = response.body.value[i].skuPartNumber
|
|
1286
|
-
for (let index = 0; index < response.body.value[i].servicePlans.length; index++) {
|
|
1287
|
-
if (response.body.value[i].servicePlans[index].provisioningStatus === 'Success' ||
|
|
1288
|
-
response.body.value[i].servicePlans[index].provisioningStatus === 'PendingInput') {
|
|
1289
|
-
const servicePlan = { value: `${skuPartNumber}::${response.body.value[i].servicePlans[index].servicePlanName}` }
|
|
1290
|
-
retObj.servicePlan.push(servicePlan)
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
resolve(retObj)
|
|
1296
|
-
} catch (err) {
|
|
1297
|
-
let statusCode
|
|
1298
|
-
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
|
|
1299
|
-
if (statusCode === 404) return resolve(null) // user have no plans
|
|
1300
|
-
return reject(err)
|
|
1301
|
-
}
|
|
1302
|
-
})()
|
|
1303
|
-
})
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// return Promise.all([user(), manager(), license()])
|
|
1307
|
-
return Promise.all([user(), license()])
|
|
1308
|
-
.then((results) => {
|
|
1309
|
-
let retObj = {}
|
|
1310
|
-
for (const i in results) { // merge async.parallell results to one
|
|
1311
|
-
retObj = Object.assign(retObj, results[i])
|
|
1312
|
-
}
|
|
1313
|
-
return retObj
|
|
1314
|
-
})
|
|
1315
|
-
.catch((err) => {
|
|
1316
|
-
if (err.message.includes('empty response')) return null // no user found
|
|
1317
|
-
else throw (err)
|
|
1318
|
-
})
|
|
1319
|
-
}
|
|
1320
1392
|
|
|
1321
1393
|
//
|
|
1322
1394
|
// Cleanup on exit
|
package/lib/plugin-ldap.js
CHANGED
|
@@ -202,7 +202,16 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
202
202
|
throw new Error(`${action} error: not supporting groups member of user filtering: ${getObj.rawFilter}`)
|
|
203
203
|
} else {
|
|
204
204
|
// optional - simpel filtering
|
|
205
|
-
|
|
205
|
+
if (getObj.operator === 'eq') {
|
|
206
|
+
ldapOptions = {
|
|
207
|
+
filter: `&${getObjClassFilter(baseEntity, 'user')}(${getObj.attribute}=${getObj.value})`, // &(objectClass=user)(objectClass=person)(objectClass=organizationalPerson)(objectClass=top)(employeeNumber=123)
|
|
208
|
+
scope: scope,
|
|
209
|
+
attributes: attrs
|
|
210
|
+
}
|
|
211
|
+
if (config.entity[baseEntity].ldap.userFilter) ldapOptions.filter += config.entity[baseEntity].ldap.userFilter
|
|
212
|
+
} else {
|
|
213
|
+
throw new Error(`${action} error: not supporting simpel filtering: ${getObj.rawFilter}`)
|
|
214
|
+
}
|
|
206
215
|
}
|
|
207
216
|
} else if (getObj.rawFilter) {
|
|
208
217
|
// optional - advanced filtering having and/or/not - use getObj.rawFilter
|
|
@@ -284,8 +293,8 @@ scimgateway.createUser = async (baseEntity, userObj, ctx) => {
|
|
|
284
293
|
if (!userBase) userBase = config.entity[baseEntity].ldap.userBase
|
|
285
294
|
|
|
286
295
|
// convert SCIM attributes to endpoint attributes according to config.map
|
|
287
|
-
const [endpointObj
|
|
288
|
-
if (err) throw new Error(`${action} error: ${err.message}`)
|
|
296
|
+
const [endpointObj] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
|
|
297
|
+
// if (err) throw new Error(`${action} error: ${err.message}`) // use above [endpointObj, err] to catch non supported attributes
|
|
289
298
|
|
|
290
299
|
// endpoint spesific attribute handling
|
|
291
300
|
if (endpointObj.sAMAccountName !== undefined) { // Active Directory
|
|
@@ -366,8 +375,8 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
366
375
|
const groups = attrObj.groups
|
|
367
376
|
delete attrObj.groups // make sure to be removed from attrObj
|
|
368
377
|
|
|
369
|
-
const [groupsAttr
|
|
370
|
-
if (err) throw new Error(`${action} error: ${err.message}`)
|
|
378
|
+
const [groupsAttr] = scimgateway.endpointMapper('outbound', 'groups.value', config.map.user)
|
|
379
|
+
// if (err) throw new Error(`${action} error: ${err.message}`) // use above [endpointObj, err] to catch non supported attributes
|
|
371
380
|
|
|
372
381
|
const body = { add: { }, remove: { } }
|
|
373
382
|
body.add[groupsAttr] = []
|
|
@@ -416,8 +425,8 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
416
425
|
if (JSON.stringify(attrObj) === '{}') return null // only groups included
|
|
417
426
|
|
|
418
427
|
// convert SCIM attributes to endpoint attributes according to config.map
|
|
419
|
-
const [endpointObj
|
|
420
|
-
if (err) throw new Error(`${action} error: ${err.message}`)
|
|
428
|
+
const [endpointObj] = scimgateway.endpointMapper('outbound', attrObj, config.map.user)
|
|
429
|
+
// if (err) throw new Error(`${action} error: ${err.message}`) // use above [endpointObj, err] to catch non supported attributes
|
|
421
430
|
|
|
422
431
|
// endpoint spesific attribute handling
|
|
423
432
|
if (endpointObj.userAccountControl !== undefined) { // SCIM "active" - Active Directory
|