scimgateway 4.2.3 → 4.2.6
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/.travis.yml +1 -1
- package/README.md +18 -0
- package/lib/plugin-api.js +47 -21
- package/lib/plugin-azure-ad.js +77 -45
- package/lib/plugin-forwardinc.js +35 -14
- package/lib/plugin-ldap.js +45 -29
- package/lib/plugin-mongodb.js +67 -19
- package/lib/plugin-mssql.js +58 -6
- package/lib/plugin-scim.js +47 -20
- package/lib/scimgateway.js +17 -1
- package/package.json +2 -2
package/.travis.yml
CHANGED
package/README.md
CHANGED
|
@@ -1165,6 +1165,24 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1165
1165
|
|
|
1166
1166
|
## Change log
|
|
1167
1167
|
|
|
1168
|
+
### v4.2.6
|
|
1169
|
+
|
|
1170
|
+
[Fixed]
|
|
1171
|
+
|
|
1172
|
+
- cosmetics related to 401 error handling introduced in v4.2.4
|
|
1173
|
+
|
|
1174
|
+
### v4.2.5
|
|
1175
|
+
|
|
1176
|
+
[Fixed]
|
|
1177
|
+
|
|
1178
|
+
- travis test build cosmetics
|
|
1179
|
+
|
|
1180
|
+
### v4.2.4
|
|
1181
|
+
|
|
1182
|
+
[Added]
|
|
1183
|
+
|
|
1184
|
+
- provided plugins now supports Auth PassThrough. See helpers methods like getClientIdentifier(), getCtxAuth() and changes in doRequest() and getServiceClient(). In general, PassThrough is supported for both basic and bearer auth. Password/secret/client_secret are then not needed in configuration file. Username may still be needed in configuration file depended on how logic is implemented (ref. mongodb/mssql) and what auth beeing used (basic/bearer). Plugin scim, api and azure-ad are all REST plugins having the same helpers (but, some minor differences to azure-ad using OAuth and the getAccessToken() method)
|
|
1185
|
+
|
|
1168
1186
|
### v4.2.3
|
|
1169
1187
|
|
|
1170
1188
|
[Fixed]
|
package/lib/plugin-api.js
CHANGED
|
@@ -77,7 +77,7 @@ scimgateway.postApi = async (baseEntity, apiObj, ctx) => {
|
|
|
77
77
|
Excerpt: apiObj.userID
|
|
78
78
|
}
|
|
79
79
|
try {
|
|
80
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
80
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
81
81
|
return response.body
|
|
82
82
|
} catch (err) {
|
|
83
83
|
const newErr = err
|
|
@@ -111,7 +111,7 @@ scimgateway.putApi = async (baseEntity, id, apiObj, ctx) => {
|
|
|
111
111
|
Excerpt: apiObj.userID
|
|
112
112
|
}
|
|
113
113
|
try {
|
|
114
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
114
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
115
115
|
return response.body
|
|
116
116
|
} catch (err) {
|
|
117
117
|
const newErr = err
|
|
@@ -144,7 +144,7 @@ scimgateway.patchApi = async (baseEntity, id, apiObj, ctx) => {
|
|
|
144
144
|
if (apiObj.userID) body.Excerpt = apiObj.userID
|
|
145
145
|
|
|
146
146
|
try { // note, Books example do not support patch
|
|
147
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
147
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
148
148
|
return response.body
|
|
149
149
|
} catch (err) {
|
|
150
150
|
const newErr = err
|
|
@@ -170,13 +170,13 @@ scimgateway.getApi = async (baseEntity, id, apiQuery, apiObj, ctx) => {
|
|
|
170
170
|
if (id) {
|
|
171
171
|
const path = `/api/v1/Books/${id}`
|
|
172
172
|
const body = null
|
|
173
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
173
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
174
174
|
return response.body
|
|
175
175
|
} else {
|
|
176
176
|
const path = '/api/Books'
|
|
177
177
|
const body = null
|
|
178
178
|
if (apiQuery) { /* some logic here */ }
|
|
179
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
179
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
180
180
|
return response.body
|
|
181
181
|
}
|
|
182
182
|
} catch (err) {
|
|
@@ -201,7 +201,7 @@ scimgateway.deleteApi = async (baseEntity, id, ctx) => {
|
|
|
201
201
|
const body = null
|
|
202
202
|
|
|
203
203
|
try {
|
|
204
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
204
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
205
205
|
return response.body
|
|
206
206
|
} catch (err) {
|
|
207
207
|
const newErr = err
|
|
@@ -213,6 +213,24 @@ scimgateway.deleteApi = async (baseEntity, id, ctx) => {
|
|
|
213
213
|
// helpers
|
|
214
214
|
// =================================================
|
|
215
215
|
|
|
216
|
+
const getClientIdentifier = (ctx) => {
|
|
217
|
+
if (!ctx?.request?.header?.authorization) return undefined
|
|
218
|
+
const [user, secret] = getCtxAuth(ctx)
|
|
219
|
+
return `${encodeURIComponent(user)}_${encodeURIComponent(secret)}` // user_password or undefined_password
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
//
|
|
223
|
+
// getCtxAuth returns username/secret from ctx header when using Auth PassThrough
|
|
224
|
+
//
|
|
225
|
+
const getCtxAuth = (ctx) => { // eslint-disable-line
|
|
226
|
+
if (!ctx?.request?.header?.authorization) return []
|
|
227
|
+
const [authType, authToken] = (ctx.request.header.authorization || '').split(' ') // [0] = 'Basic' or 'Bearer'
|
|
228
|
+
let username, password
|
|
229
|
+
if (authType === 'Basic') [username, password] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
|
|
230
|
+
if (username) return [username, password] // basic auth
|
|
231
|
+
else return [undefined, authToken] // bearer auth
|
|
232
|
+
}
|
|
233
|
+
|
|
216
234
|
//
|
|
217
235
|
// getServiceClient - returns options needed for connection parameters
|
|
218
236
|
//
|
|
@@ -222,7 +240,7 @@ scimgateway.deleteApi = async (baseEntity, id, ctx) => {
|
|
|
222
240
|
// path = url e.g. "http(s)://<host>:<port>/xxx/yyy", then using the url host/port/protocol
|
|
223
241
|
// opt (options) may be needed e.g {auth: {username: "username", password: "password"} }
|
|
224
242
|
//
|
|
225
|
-
const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
243
|
+
const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
226
244
|
const action = 'getServiceClient'
|
|
227
245
|
|
|
228
246
|
let urlObj
|
|
@@ -233,15 +251,15 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
233
251
|
//
|
|
234
252
|
// path (no url) - default approach and client will be cached based on config
|
|
235
253
|
//
|
|
236
|
-
|
|
254
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
255
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier]) { // serviceClient already exist
|
|
237
256
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`)
|
|
238
257
|
} else {
|
|
239
258
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Client have to be created`)
|
|
240
259
|
let client = null
|
|
241
260
|
if (config.entity && config.entity[baseEntity]) client = config.entity[baseEntity]
|
|
242
261
|
if (!client) {
|
|
243
|
-
|
|
244
|
-
throw err
|
|
262
|
+
throw new Error(`Base URL have baseEntity=${baseEntity}, and configuration file ${pluginName}.json is missing required baseEntity configuration for ${baseEntity}`)
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
urlObj = new URL(config.entity[baseEntity].baseUrls[0])
|
|
@@ -251,7 +269,8 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
251
269
|
json: true, // json-object response instead of string
|
|
252
270
|
headers: {
|
|
253
271
|
'Content-Type': 'application/json',
|
|
254
|
-
|
|
272
|
+
// Auth PassThrough or configuration, using ctx "AS-IS" header for PassThrough. For more advanced logic use getCtxAuth(ctx) - see examples in other plugins
|
|
273
|
+
Authorization: ctx?.request?.header?.authorization ? ctx.request.header.authorization : 'Basic ' + Buffer.from(`${config.entity[baseEntity].username}:${scimgateway.getPassword(`endpoint.entity.${baseEntity}.password`, configFile)}`).toString('base64')
|
|
255
274
|
},
|
|
256
275
|
host: urlObj.hostname,
|
|
257
276
|
port: urlObj.port, // null if https and 443 defined in url
|
|
@@ -271,13 +290,14 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
271
290
|
}
|
|
272
291
|
|
|
273
292
|
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
|
|
274
|
-
_serviceClient[baseEntity] =
|
|
293
|
+
if (!_serviceClient[baseEntity][clientIdentifier]) _serviceClient[baseEntity][clientIdentifier] = {}
|
|
294
|
+
_serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
|
|
275
295
|
}
|
|
276
296
|
|
|
277
|
-
const cli = scimgateway.copyObj(_serviceClient[baseEntity]) // client ready
|
|
297
|
+
const cli = scimgateway.copyObj(_serviceClient[baseEntity][clientIdentifier]) // client ready
|
|
278
298
|
|
|
279
299
|
// failover support
|
|
280
|
-
path = _serviceClient[baseEntity].baseUrl + path
|
|
300
|
+
path = _serviceClient[baseEntity][clientIdentifier].baseUrl + path
|
|
281
301
|
urlObj = new URL(path)
|
|
282
302
|
cli.options.host = urlObj.hostname
|
|
283
303
|
cli.options.port = urlObj.port
|
|
@@ -330,16 +350,16 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
330
350
|
return cli // final client
|
|
331
351
|
}
|
|
332
352
|
|
|
333
|
-
const updateServiceClient = (baseEntity, obj) => {
|
|
334
|
-
if (_serviceClient[baseEntity]) _serviceClient[baseEntity] = scimgateway.extendObj(_serviceClient[baseEntity], obj) // merge with argument options
|
|
353
|
+
const updateServiceClient = (baseEntity, clientIdentifier, obj) => {
|
|
354
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier]) _serviceClient[baseEntity][clientIdentifier] = scimgateway.extendObj(_serviceClient[baseEntity][clientIdentifier], obj) // merge with argument options
|
|
335
355
|
}
|
|
336
356
|
|
|
337
357
|
//
|
|
338
358
|
// doRequest - execute REST service
|
|
339
359
|
//
|
|
340
|
-
const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
360
|
+
const doRequest = async (baseEntity, method, path, body, ctx, opt, retryCount) => {
|
|
341
361
|
try {
|
|
342
|
-
const cli = await getServiceClient(baseEntity, method, path, opt)
|
|
362
|
+
const cli = await getServiceClient(baseEntity, method, path, opt, ctx)
|
|
343
363
|
const options = cli.options
|
|
344
364
|
const result = await new Promise((resolve, reject) => {
|
|
345
365
|
let dataString = ''
|
|
@@ -394,15 +414,18 @@ const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
|
394
414
|
return result
|
|
395
415
|
} catch (err) { // includes failover/retry logic based on config baseUrls array
|
|
396
416
|
scimgateway.logger.error(`${pluginName}[${baseEntity}] doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
417
|
+
let statusCode
|
|
418
|
+
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
|
|
419
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
397
420
|
if (!retryCount) retryCount = 0
|
|
398
421
|
let urlObj
|
|
399
422
|
try { urlObj = new URL(path) } catch (err) {}
|
|
400
423
|
if (!urlObj && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND')) {
|
|
401
424
|
if (retryCount < config.entity[baseEntity].baseUrls.length) {
|
|
402
425
|
retryCount++
|
|
403
|
-
updateServiceClient(baseEntity, { baseUrl: config.entity[baseEntity].baseUrls[retryCount - 1] })
|
|
426
|
+
updateServiceClient(baseEntity, clientIdentifier, { baseUrl: config.entity[baseEntity].baseUrls[retryCount - 1] })
|
|
404
427
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${(config.entity[baseEntity].baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${_serviceClient[baseEntity].baseUrl}`)
|
|
405
|
-
const ret = await doRequest(baseEntity, method, path, body, opt, retryCount) // retry
|
|
428
|
+
const ret = await doRequest(baseEntity, method, path, body, ctx, opt, retryCount) // retry
|
|
406
429
|
return ret // problem fixed
|
|
407
430
|
} else {
|
|
408
431
|
const newerr = new Error(err.message)
|
|
@@ -410,7 +433,10 @@ const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
|
410
433
|
newerr.message = newerr.message.replace('ENOTFOUND', 'UnableConnectingHost') // avoid returning ENOTFOUND error
|
|
411
434
|
throw newerr
|
|
412
435
|
}
|
|
413
|
-
} else
|
|
436
|
+
} else {
|
|
437
|
+
if (statusCode === 401 && _serviceClient[baseEntity]) delete _serviceClient[baseEntity][clientIdentifier]
|
|
438
|
+
throw err // CA IM retries getUsers failure once (retry 6 times on ECONNREFUSED)
|
|
439
|
+
}
|
|
414
440
|
}
|
|
415
441
|
} // doRequest
|
|
416
442
|
|
package/lib/plugin-azure-ad.js
CHANGED
|
@@ -128,7 +128,9 @@ for (const key in config.map.user) { // userAttributes = ['country', 'preferredL
|
|
|
128
128
|
if (config.map.user[key].mapTo) userAttributes.push(config.map.user[key].mapTo)
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
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].baseUrls = ['https://graph.microsoft.com/beta'] // beta instead of 'v1.0' gives all user attributes when no $select
|
|
133
|
+
}
|
|
132
134
|
|
|
133
135
|
const _serviceClient = {}
|
|
134
136
|
const lock = new scimgateway.Lock()
|
|
@@ -211,9 +213,9 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
211
213
|
let response
|
|
212
214
|
if (path === 'getUser') { // special
|
|
213
215
|
response = { body: { value: [] } }
|
|
214
|
-
const userObj = await getUser(baseEntity, getObj.value, attributes)
|
|
216
|
+
const userObj = await getUser(baseEntity, getObj.value, attributes, ctx)
|
|
215
217
|
if (userObj) response.body.value.push(userObj)
|
|
216
|
-
} else response = await doRequest(baseEntity, method, path, body)
|
|
218
|
+
} else response = await doRequest(baseEntity, method, path, body, ctx)
|
|
217
219
|
if (!response.body.value) {
|
|
218
220
|
throw new Error(`invalid response: ${JSON.stringify(response)}`)
|
|
219
221
|
}
|
|
@@ -258,7 +260,7 @@ scimgateway.createUser = async (baseEntity, userObj, ctx) => {
|
|
|
258
260
|
const [body] = scimgateway.endpointMapper('outbound', userObj, config.map.user)
|
|
259
261
|
|
|
260
262
|
try {
|
|
261
|
-
await doRequest(baseEntity, method, path, body)
|
|
263
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
262
264
|
if (Object.keys(addonObj).length > 0) {
|
|
263
265
|
await scimgateway.modifyUser(baseEntity, userObj.userName, addonObj, ctx) // manager, servicePlan
|
|
264
266
|
return null
|
|
@@ -281,7 +283,7 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
|
|
|
281
283
|
const body = null
|
|
282
284
|
|
|
283
285
|
try {
|
|
284
|
-
await doRequest(baseEntity, method, path, body)
|
|
286
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
285
287
|
return (null)
|
|
286
288
|
} catch (err) {
|
|
287
289
|
throw new Error(`${action} error: ${err.message}`)
|
|
@@ -326,7 +328,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
326
328
|
const method = 'GET'
|
|
327
329
|
const path = `/users/${id}?$select=${key}`
|
|
328
330
|
try {
|
|
329
|
-
const res = await doRequest(baseEntity, method, path, null)
|
|
331
|
+
const res = await doRequest(baseEntity, method, path, null, ctx)
|
|
330
332
|
if (res && res.body && res.body[key]) {
|
|
331
333
|
const fullKeyObj = Object.assign(res.body[key], parsedAttrObj[key]) // merge original with modified
|
|
332
334
|
if (fullKeyObj && Object.keys(fullKeyObj).length > 0) {
|
|
@@ -341,7 +343,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
341
343
|
const method = 'PATCH'
|
|
342
344
|
const path = `/users/${id}`
|
|
343
345
|
try {
|
|
344
|
-
await doRequest(baseEntity, method, path, parsedAttrObj)
|
|
346
|
+
await doRequest(baseEntity, method, path, parsedAttrObj, ctx)
|
|
345
347
|
resolve(null)
|
|
346
348
|
} catch (err) {
|
|
347
349
|
return reject(err)
|
|
@@ -357,6 +359,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
357
359
|
let path = null
|
|
358
360
|
let body = null
|
|
359
361
|
if (objManager.manager) { // new manager
|
|
362
|
+
const graphUrl = config.entity[baseEntity].baseUrls[0]
|
|
360
363
|
method = 'PUT'
|
|
361
364
|
path = `/users/${id}/manager/$ref`
|
|
362
365
|
body = { '@odata.id': `${graphUrl}/users/${objManager.manager}` }
|
|
@@ -366,7 +369,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
366
369
|
body = null
|
|
367
370
|
} else return resolve(null)
|
|
368
371
|
try {
|
|
369
|
-
await doRequest(baseEntity, method, path, body)
|
|
372
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
370
373
|
resolve(null)
|
|
371
374
|
} catch (err) {
|
|
372
375
|
return reject(err)
|
|
@@ -387,7 +390,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
387
390
|
try { // build currentLic
|
|
388
391
|
let response
|
|
389
392
|
try {
|
|
390
|
-
response = await doRequest(baseEntity, method, path, null)
|
|
393
|
+
response = await doRequest(baseEntity, method, path, null, ctx)
|
|
391
394
|
} catch (err) {
|
|
392
395
|
let statusCode
|
|
393
396
|
try { statusCode = JSON.parse(err.message).statusCode } catch (e) {}
|
|
@@ -419,7 +422,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
419
422
|
const addLic = {}
|
|
420
423
|
const removeLic = {}
|
|
421
424
|
|
|
422
|
-
response = await doRequest(baseEntity, method, path, null)
|
|
425
|
+
response = await doRequest(baseEntity, method, path, null, ctx)
|
|
423
426
|
if (!response.body.value) {
|
|
424
427
|
const err = new Error(`${action}: Got empty response on REST request`)
|
|
425
428
|
return reject(err)
|
|
@@ -504,7 +507,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
|
|
|
504
507
|
method = 'POST'
|
|
505
508
|
path = `/users/${id}/assignLicense`
|
|
506
509
|
const body = lic
|
|
507
|
-
await doRequest(baseEntity, method, path, body)
|
|
510
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
508
511
|
|
|
509
512
|
resolve(null)
|
|
510
513
|
} catch (err) {
|
|
@@ -611,7 +614,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
611
614
|
if (!path) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
|
|
612
615
|
|
|
613
616
|
try {
|
|
614
|
-
let response = await doRequest(baseEntity, method, path, body)
|
|
617
|
+
let response = await doRequest(baseEntity, method, path, body, ctx)
|
|
615
618
|
if (!response.body) {
|
|
616
619
|
throw new Error(`invalid response: ${JSON.stringify(response)}`)
|
|
617
620
|
}
|
|
@@ -672,7 +675,7 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
|
|
|
672
675
|
if (res && res.Resources && res.Resources.length > 0) {
|
|
673
676
|
throw new Error(`group ${groupObj.displayName} already exist`)
|
|
674
677
|
}
|
|
675
|
-
await doRequest(baseEntity, method, path, body)
|
|
678
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
676
679
|
return null
|
|
677
680
|
} catch (err) {
|
|
678
681
|
const newErr = new Error(`${action} error: ${err.message}`)
|
|
@@ -720,10 +723,11 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
|
|
|
720
723
|
if (arrGrpAdd.length < 1) return resolve(null)
|
|
721
724
|
const method = 'POST'
|
|
722
725
|
const path = `/groups/${id}/members/$ref`
|
|
726
|
+
const graphUrl = config.entity[baseEntity].baseUrls[0]
|
|
723
727
|
for (let i = 0, len = arrGrpAdd.length; i < len; i++) {
|
|
724
728
|
const body = { '@odata.id': `${graphUrl}/directoryObjects/${arrGrpAdd[i]}` }
|
|
725
729
|
try {
|
|
726
|
-
await doRequest(baseEntity, method, path, body)
|
|
730
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
727
731
|
if (i === len - 1) resolve(null) // loop completed
|
|
728
732
|
} catch (err) {
|
|
729
733
|
return reject(err)
|
|
@@ -742,7 +746,7 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
|
|
|
742
746
|
for (let i = 0, len = arrGrpDel.length; i < len; i++) {
|
|
743
747
|
const path = `/groups/${id}/members/${arrGrpDel[i]}/$ref`
|
|
744
748
|
try {
|
|
745
|
-
await doRequest(baseEntity, method, path, body)
|
|
749
|
+
await doRequest(baseEntity, method, path, body, ctx)
|
|
746
750
|
if (i === len - 1) resolve(null) // loop completed
|
|
747
751
|
} catch (err) {
|
|
748
752
|
return reject(err)
|
|
@@ -821,7 +825,7 @@ scimgateway.getServicePlans = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
821
825
|
response = { body: { value: [] } }
|
|
822
826
|
path = '/subscribedSkus'
|
|
823
827
|
|
|
824
|
-
const res = await doRequest(baseEntity, method, path, body)
|
|
828
|
+
const res = await doRequest(baseEntity, method, path, body, ctx)
|
|
825
829
|
if (!res.body.value) {
|
|
826
830
|
throw new Error('got empty response on REST request')
|
|
827
831
|
}
|
|
@@ -850,7 +854,7 @@ scimgateway.getServicePlans = async (baseEntity, getObj, attributes, ctx) => {
|
|
|
850
854
|
}
|
|
851
855
|
if (planObj) ret.Resources.push(planObj)
|
|
852
856
|
} else {
|
|
853
|
-
response = await doRequest(baseEntity, method, path, body)
|
|
857
|
+
response = await doRequest(baseEntity, method, path, body, ctx)
|
|
854
858
|
|
|
855
859
|
if (!response.body.value) {
|
|
856
860
|
throw new Error('got empty response on REST request')
|
|
@@ -908,6 +912,24 @@ scimgateway.modifyServicePlan = async (baseEntity, id, ctx) => {
|
|
|
908
912
|
// helpers
|
|
909
913
|
// =================================================
|
|
910
914
|
|
|
915
|
+
const getClientIdentifier = (ctx) => {
|
|
916
|
+
if (!ctx?.request?.header?.authorization) return undefined
|
|
917
|
+
const [user, secret] = getCtxAuth(ctx)
|
|
918
|
+
return `${encodeURIComponent(user)}_${encodeURIComponent(secret)}` // user_password or undefined_password
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
//
|
|
922
|
+
// getCtxAuth returns username/secret from ctx header when using Auth PassThrough
|
|
923
|
+
//
|
|
924
|
+
const getCtxAuth = (ctx) => {
|
|
925
|
+
if (!ctx?.request?.header?.authorization) return []
|
|
926
|
+
const [authType, authToken] = (ctx.request.header.authorization || '').split(' ') // [0] = 'Basic' or 'Bearer'
|
|
927
|
+
let username, password
|
|
928
|
+
if (authType === 'Basic') [username, password] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
|
|
929
|
+
if (username) return [username, password] // basic auth
|
|
930
|
+
else return [undefined, authToken] // bearer auth
|
|
931
|
+
}
|
|
932
|
+
|
|
911
933
|
//
|
|
912
934
|
// getServiceClient - returns options needed for connection parameters
|
|
913
935
|
//
|
|
@@ -917,7 +939,7 @@ scimgateway.modifyServicePlan = async (baseEntity, id, ctx) => {
|
|
|
917
939
|
// path = url e.g. "http(s)://<host>:<port>/xxx/yyy", then using the url host/port/protocol
|
|
918
940
|
// opt (options) may be needed e.g {auth: {username: "username", password: "password"} }
|
|
919
941
|
//
|
|
920
|
-
const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
942
|
+
const getServiceClient = async (baseEntity, method, path, opt, ctx) => {
|
|
921
943
|
const action = 'getServiceClient'
|
|
922
944
|
|
|
923
945
|
let urlObj
|
|
@@ -928,17 +950,20 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
928
950
|
//
|
|
929
951
|
// path (no url) - default approach and client will be cached based on config
|
|
930
952
|
//
|
|
931
|
-
|
|
953
|
+
|
|
954
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
955
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier] && _serviceClient[baseEntity][clientIdentifier].accessToken) { // serviceClient already exist - Azure plugin specific
|
|
932
956
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Using existing client`)
|
|
933
957
|
// check if token refresh is needed
|
|
934
958
|
const d = new Date() / 1000 // seconds (unix time)
|
|
935
|
-
if (_serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
|
|
936
|
-
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Accesstoken about to expire in ${_serviceClient[baseEntity].accessToken.validTo - d} seconds`)
|
|
959
|
+
if (_serviceClient[baseEntity][clientIdentifier].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
|
|
960
|
+
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${action}: Accesstoken about to expire in ${_serviceClient[baseEntity][clientIdentifier].accessToken.validTo - d} seconds`)
|
|
937
961
|
try {
|
|
938
|
-
const accessToken = await getAccessToken(baseEntity)
|
|
939
|
-
_serviceClient[baseEntity].accessToken = accessToken
|
|
940
|
-
_serviceClient[baseEntity].options.headers.Authorization = ` Bearer ${accessToken.access_token}`
|
|
962
|
+
const accessToken = await getAccessToken(baseEntity, ctx)
|
|
963
|
+
_serviceClient[baseEntity][clientIdentifier].accessToken = accessToken
|
|
964
|
+
_serviceClient[baseEntity][clientIdentifier].options.headers.Authorization = ` Bearer ${accessToken.access_token}`
|
|
941
965
|
} catch (err) {
|
|
966
|
+
delete _serviceClient[baseEntity][clientIdentifier]
|
|
942
967
|
const newErr = err
|
|
943
968
|
throw newErr
|
|
944
969
|
}
|
|
@@ -953,8 +978,7 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
953
978
|
}
|
|
954
979
|
|
|
955
980
|
// Azure plugin specific
|
|
956
|
-
const accessToken = await getAccessToken(baseEntity)
|
|
957
|
-
if (!config.entity[baseEntity].baseUrls) config.entity[baseEntity].baseUrls = [graphUrl] // Azure plugin avoid config file and keep baseUrls logic
|
|
981
|
+
const accessToken = await getAccessToken(baseEntity, ctx)
|
|
958
982
|
|
|
959
983
|
urlObj = new URL(config.entity[baseEntity].baseUrls[0])
|
|
960
984
|
const param = {
|
|
@@ -983,18 +1007,20 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
983
1007
|
}
|
|
984
1008
|
|
|
985
1009
|
if (!_serviceClient[baseEntity]) _serviceClient[baseEntity] = {}
|
|
986
|
-
_serviceClient[baseEntity] =
|
|
1010
|
+
if (!_serviceClient[baseEntity][clientIdentifier]) _serviceClient[baseEntity][clientIdentifier] = {}
|
|
987
1011
|
|
|
988
|
-
//
|
|
1012
|
+
_serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
|
|
1013
|
+
|
|
1014
|
+
// Azure plugin specific (note, not using [clientIdentifier])
|
|
989
1015
|
_serviceClient[baseEntity].nextLink = {}
|
|
990
1016
|
_serviceClient[baseEntity].nextLink.users = null // Azure users pagination
|
|
991
1017
|
_serviceClient[baseEntity].nextLink.groups = null // Azure groups pagination
|
|
992
1018
|
}
|
|
993
1019
|
|
|
994
|
-
const cli = scimgateway.copyObj(_serviceClient[baseEntity]) // client ready
|
|
1020
|
+
const cli = scimgateway.copyObj(_serviceClient[baseEntity][clientIdentifier]) // client ready
|
|
995
1021
|
|
|
996
1022
|
// failover support
|
|
997
|
-
path = _serviceClient[baseEntity].baseUrl + path
|
|
1023
|
+
path = _serviceClient[baseEntity][clientIdentifier].baseUrl + path
|
|
998
1024
|
urlObj = new URL(path)
|
|
999
1025
|
cli.options.host = urlObj.hostname
|
|
1000
1026
|
cli.options.port = urlObj.port
|
|
@@ -1047,16 +1073,16 @@ const getServiceClient = async (baseEntity, method, path, opt) => {
|
|
|
1047
1073
|
return cli // final client
|
|
1048
1074
|
}
|
|
1049
1075
|
|
|
1050
|
-
const updateServiceClient = (baseEntity, obj) => {
|
|
1051
|
-
if (_serviceClient[baseEntity]) _serviceClient[baseEntity] = scimgateway.extendObj(_serviceClient[baseEntity], obj) // merge with argument options
|
|
1076
|
+
const updateServiceClient = (baseEntity, clientIdentifier, obj) => {
|
|
1077
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier]) _serviceClient[baseEntity][clientIdentifier] = scimgateway.extendObj(_serviceClient[baseEntity][clientIdentifier], obj) // merge with argument options
|
|
1052
1078
|
}
|
|
1053
1079
|
|
|
1054
1080
|
//
|
|
1055
1081
|
// doRequest - execute REST service
|
|
1056
1082
|
//
|
|
1057
|
-
const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
1083
|
+
const doRequest = async (baseEntity, method, path, body, ctx, opt, retryCount) => {
|
|
1058
1084
|
try {
|
|
1059
|
-
const cli = await getServiceClient(baseEntity, method, path, opt)
|
|
1085
|
+
const cli = await getServiceClient(baseEntity, method, path, opt, ctx)
|
|
1060
1086
|
const options = cli.options
|
|
1061
1087
|
|
|
1062
1088
|
const result = await new Promise((resolve, reject) => {
|
|
@@ -1116,15 +1142,16 @@ const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
|
1116
1142
|
if (statusCode === 404) { // not logged as error, let caller decide e.g. getUser-manager
|
|
1117
1143
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
1118
1144
|
} else scimgateway.logger.error(`${pluginName}[${baseEntity}] doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
1145
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
1119
1146
|
if (!retryCount) retryCount = 0
|
|
1120
1147
|
let urlObj
|
|
1121
1148
|
try { urlObj = new URL(path) } catch (err) {}
|
|
1122
1149
|
if (!urlObj && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT')) {
|
|
1123
1150
|
if (retryCount < config.entity[baseEntity].baseUrls.length) {
|
|
1124
1151
|
retryCount++
|
|
1125
|
-
updateServiceClient(baseEntity, { baseUrl: config.entity[baseEntity].baseUrls[retryCount - 1] })
|
|
1152
|
+
updateServiceClient(baseEntity, clientIdentifier, { baseUrl: config.entity[baseEntity].baseUrls[retryCount - 1] })
|
|
1126
1153
|
scimgateway.logger.debug(`${pluginName}[${baseEntity}] ${(config.entity[baseEntity].baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${_serviceClient[baseEntity].baseUrl}`)
|
|
1127
|
-
const ret = await doRequest(baseEntity, method, path, body, opt, retryCount) // retry
|
|
1154
|
+
const ret = await doRequest(baseEntity, method, path, body, ctx, opt, retryCount) // retry
|
|
1128
1155
|
return ret // problem fixed
|
|
1129
1156
|
} else {
|
|
1130
1157
|
const newerr = new Error(err.message)
|
|
@@ -1132,17 +1159,21 @@ const doRequest = async (baseEntity, method, path, body, opt, retryCount) => {
|
|
|
1132
1159
|
newerr.message = newerr.message.replace('ENOTFOUND', 'UnableConnectingHost') // avoid returning ENOTFOUND error
|
|
1133
1160
|
throw newerr
|
|
1134
1161
|
}
|
|
1135
|
-
} else
|
|
1162
|
+
} else {
|
|
1163
|
+
if (statusCode === 401 && _serviceClient[baseEntity]) delete _serviceClient[baseEntity][clientIdentifier]
|
|
1164
|
+
throw err // CA IM retries getUser failure once (retry 6 times on ECONNREFUSED)
|
|
1165
|
+
}
|
|
1136
1166
|
}
|
|
1137
1167
|
} // doRequest
|
|
1138
1168
|
|
|
1139
1169
|
//
|
|
1140
1170
|
// getAccessToken - returns oauth jwt accesstoken
|
|
1141
1171
|
//
|
|
1142
|
-
const getAccessToken = async (baseEntity) => {
|
|
1172
|
+
const getAccessToken = async (baseEntity, ctx) => {
|
|
1143
1173
|
await lock.acquire()
|
|
1174
|
+
const clientIdentifier = getClientIdentifier(ctx)
|
|
1144
1175
|
const d = new Date() / 1000 // seconds (unix time)
|
|
1145
|
-
if (_serviceClient[baseEntity] && _serviceClient[baseEntity].accessToken &&
|
|
1176
|
+
if (_serviceClient[baseEntity] && _serviceClient[baseEntity][clientIdentifier] && _serviceClient[baseEntity][clientIdentifier].accessToken &&
|
|
1146
1177
|
(_serviceClient[baseEntity].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
|
|
1147
1178
|
lock.release()
|
|
1148
1179
|
return _serviceClient[baseEntity].accessToken
|
|
@@ -1154,10 +1185,11 @@ const getAccessToken = async (baseEntity) => {
|
|
|
1154
1185
|
const req = `https://login.microsoftonline.com/${config.entity[baseEntity].tenantIdGUID}/oauth2/token`
|
|
1155
1186
|
const method = 'POST'
|
|
1156
1187
|
|
|
1157
|
-
const
|
|
1188
|
+
const [, secret] = getCtxAuth(ctx) // if Auth PassTrough, secret from basic or bearer auth
|
|
1189
|
+
const form = { // query string formatted
|
|
1158
1190
|
grant_type: 'client_credentials',
|
|
1159
1191
|
client_id: config.entity[baseEntity].clientId,
|
|
1160
|
-
client_secret: scimgateway.getPassword(`endpoint.entity.${baseEntity}.clientSecret`, configFile),
|
|
1192
|
+
client_secret: secret || scimgateway.getPassword(`endpoint.entity.${baseEntity}.clientSecret`, configFile), // using config if no Auth PassThrough
|
|
1161
1193
|
resource: 'https://graph.microsoft.com'
|
|
1162
1194
|
}
|
|
1163
1195
|
|
|
@@ -1168,7 +1200,7 @@ const getAccessToken = async (baseEntity) => {
|
|
|
1168
1200
|
}
|
|
1169
1201
|
|
|
1170
1202
|
try {
|
|
1171
|
-
const response = await doRequest(baseEntity, method, req, form, options)
|
|
1203
|
+
const response = await doRequest(baseEntity, method, req, form, ctx, options)
|
|
1172
1204
|
if (!response.body) {
|
|
1173
1205
|
const err = new Error(`[${action}] No data retrieved from: ${method} ${req}`)
|
|
1174
1206
|
throw (err)
|
|
@@ -1194,7 +1226,7 @@ const getAccessToken = async (baseEntity) => {
|
|
|
1194
1226
|
}
|
|
1195
1227
|
}
|
|
1196
1228
|
|
|
1197
|
-
const getUser = async (baseEntity, uid, attributes) => { // uid = id, userName (upn) or externalId (upn)
|
|
1229
|
+
const getUser = async (baseEntity, uid, attributes, ctx) => { // uid = id, userName (upn) or externalId (upn)
|
|
1198
1230
|
if (attributes.length < 1) {
|
|
1199
1231
|
attributes = userAttributes
|
|
1200
1232
|
}
|
|
@@ -1207,7 +1239,7 @@ const getUser = async (baseEntity, uid, attributes) => { // uid = id, userName (
|
|
|
1207
1239
|
const path = `/users/${querystring.escape(uid)}?$expand=manager($select=userPrincipalName)` // beta returns all attributes or use: ?$select=${attrs.join()}
|
|
1208
1240
|
const body = null
|
|
1209
1241
|
try {
|
|
1210
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
1242
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
1211
1243
|
const userObj = response.body
|
|
1212
1244
|
if (!userObj) {
|
|
1213
1245
|
const err = new Error('Got empty response when retrieving data for ' + uid)
|
|
@@ -1237,7 +1269,7 @@ const getUser = async (baseEntity, uid, attributes) => { // uid = id, userName (
|
|
|
1237
1269
|
const retObj = { servicePlan: [] }
|
|
1238
1270
|
|
|
1239
1271
|
try {
|
|
1240
|
-
const response = await doRequest(baseEntity, method, path, body)
|
|
1272
|
+
const response = await doRequest(baseEntity, method, path, body, ctx)
|
|
1241
1273
|
if (!response.body.value) {
|
|
1242
1274
|
const err = new Error('No content for license information ' + uid)
|
|
1243
1275
|
return reject(err)
|