scimgateway 5.0.10 → 5.0.12

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
@@ -18,7 +18,7 @@ Latest news:
18
18
 
19
19
  - Major version **v5.0.0** marks a shift to native TypeScript support and prioritizes [Bun](https://bun.sh/) over Node.js. This upgrade requires some modifications to existing plugins.
20
20
  - **BREAKING**: [SCIM Stream](https://elshaug.xyz/docs/scim-stream) is the modern way of user provisioning letting clients subscribe to messages instead of traditional IGA top-down provisioning. SCIM Gateway now offers enhanced functionality with support for message subscription and automated provisioning using SCIM Stream
21
- - Authentication PassThrough letting plugin pass authentication directly to endpoint for avoid maintaining secrets at the gateway
21
+ - Authentication PassThrough letting plugin pass authentication directly to endpoint for avoid maintaining secrets at the gateway. E.g., using Entra ID application OAuth
22
22
  - Supports OAuth Client Credentials authentication
23
23
  - Major version **v4.0.0** getUsers() and getGroups() replacing some deprecated methods. No limitations on filtering/sorting. Admin user access can be linked to specific baseEntities. New MongoDB plugin
24
24
  - ipAllowList for restricting access to allowlisted IP addresses or subnets e.g. Azure IP-range
@@ -1111,6 +1111,24 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1111
1111
 
1112
1112
  ## Change log
1113
1113
 
1114
+ ### v5.0.12
1115
+
1116
+ [Fixed]
1117
+
1118
+ - HelperRest doRequest() incorrect Auth PassThrough handling
1119
+
1120
+ [Improved]
1121
+
1122
+ - Dependencies bump
1123
+
1124
+
1125
+ ### v5.0.11
1126
+
1127
+ [Fixed]
1128
+
1129
+ - OAuth token response on error missing error_description in v5
1130
+ - HelperRest doRequest() now also includes retry logic on invalid token that has not expired - will renew token
1131
+
1114
1132
  ### v5.0.10
1115
1133
 
1116
1134
  [Improved]
package/bun.lockb CHANGED
Binary file
@@ -43,31 +43,6 @@ export class HelperRest {
43
43
  if (errMsg) this.scimgateway.logError('undefined', errMsg)
44
44
  }
45
45
 
46
- /**
47
- * getClientIdentifier returns a unique client identifier having format user_secret
48
- * @param ctx having format { autorization: "<type>:xxxxx" }
49
- * @returns user_secret
50
- **/
51
- private getClientIdentifier(ctx: Record<string, any> | undefined): string {
52
- if (!ctx?.headers?.authorization) return 'undefined'
53
- const [user, secret] = this.getCtxAuth(ctx)
54
- return `${encodeURIComponent(user)}_${encodeURIComponent(secret)}` // user_password or undefined_password
55
- }
56
-
57
- /**
58
- * getCtxAuth returns [username, secret] based on Auth PassThrough autorization header included in ctx
59
- * @param ctx includes Auth PassThrough having format {headers:{autorization:"<type>:xxxxx"}}
60
- * @returns [username, secret]
61
- **/
62
- private getCtxAuth(ctx: Record<string, any> | undefined): any[] {
63
- if (!ctx?.headers?.authorization) return []
64
- const [authType, authToken] = (ctx.headers.authorization || '').split(' ') // [0] = 'Basic' or 'Bearer'
65
- let username, password
66
- if (authType === 'Basic') [username, password] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
67
- if (username) return [username, password] // basic auth
68
- else return [undefined, authToken] // bearer auth
69
- }
70
-
71
46
  /**
72
47
  * getAccessToken returns oauth accesstoken object
73
48
  * @param baseEntity
@@ -76,12 +51,11 @@ export class HelperRest {
76
51
  */
77
52
  public async getAccessToken(baseEntity: string, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
78
53
  await this.lock.acquire()
79
- const clientIdentifier = this.getClientIdentifier(ctx)
80
54
  const d = Math.floor(Date.now() / 1000) // seconds (unix time)
81
- if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity][clientIdentifier] && this._serviceClient[baseEntity][clientIdentifier].accessToken
82
- && (this._serviceClient[baseEntity][clientIdentifier].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
55
+ if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity].accessToken
56
+ && (this._serviceClient[baseEntity].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
83
57
  this.lock.release()
84
- return this._serviceClient[baseEntity][clientIdentifier].accessToken
58
+ return this._serviceClient[baseEntity].accessToken
85
59
  }
86
60
 
87
61
  const action = 'getAccessToken'
@@ -166,13 +140,11 @@ export class HelperRest {
166
140
  const response = await this.doRequest(baseEntity, method, tokenUrl, form, ctx, connOpt)
167
141
  if (!response.body) {
168
142
  const err = new Error(`[${action}] No data retrieved from: ${method} ${tokenUrl}`)
169
- this.lock.release()
170
143
  throw (err)
171
144
  }
172
145
  const jbody = response.body
173
146
  if (jbody.error) {
174
147
  const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
175
- this.lock.release()
176
148
  throw (err)
177
149
  }
178
150
  if (this.config_entity[baseEntity]?.connection?.auth?.type === 'token') { // in case response using token instead of access_token
@@ -180,7 +152,6 @@ export class HelperRest {
180
152
  else if (jbody.accessToken) jbody.access_token = jbody.accessToken
181
153
  }
182
154
  if (!jbody.access_token) {
183
- this.lock.release()
184
155
  const err = new Error(`[${action}] Error message: Retrieved invalid token response`)
185
156
  throw (err)
186
157
  }
@@ -216,20 +187,19 @@ export class HelperRest {
216
187
  //
217
188
  // path (no url) - default approach and client will be cached based on config
218
189
  //
219
- const clientIdentifier = this.getClientIdentifier(ctx)
220
- if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity][clientIdentifier]) { // serviceClient already exist - token specific
190
+ if (this._serviceClient[baseEntity]) { // serviceClient already exist - token specific
221
191
  this.scimgateway.logDebug(baseEntity, `${action}: Using existing client`)
222
- if (this._serviceClient[baseEntity][clientIdentifier].accessToken) {
192
+ if (this._serviceClient[baseEntity].accessToken) {
223
193
  // check if token refresh is needed when using oauth
224
194
  const d = Math.floor(Date.now() / 1000) // seconds (unix time)
225
- if (this._serviceClient[baseEntity][clientIdentifier].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
226
- this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity][clientIdentifier].accessToken.validTo - d} seconds`)
195
+ if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
196
+ this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
227
197
  try {
228
198
  const accessToken = await this.getAccessToken(baseEntity, ctx)
229
- this._serviceClient[baseEntity][clientIdentifier].accessToken = accessToken
230
- this._serviceClient[baseEntity][clientIdentifier].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
199
+ this._serviceClient[baseEntity].accessToken = accessToken
200
+ this._serviceClient[baseEntity].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
231
201
  } catch (err) {
232
- delete this._serviceClient[baseEntity][clientIdentifier]
202
+ delete this._serviceClient[baseEntity]
233
203
  const newErr = err
234
204
  throw newErr
235
205
  }
@@ -263,53 +233,49 @@ export class HelperRest {
263
233
  }
264
234
 
265
235
  // Supporting no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
266
- // basicAuth, bearerAuth, oauth, tokenAuth and auth PassTrough using request header authorization
267
- if (ctx?.headers?.authorization) { // Auth PassThrough using ctx header
268
- param.options.headers['Authorization'] = ctx.headers.authorization
269
- } else {
270
- switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
271
- case 'basic':
272
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
273
- const err = new Error(`auth type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
274
- throw err
275
- }
276
- param.options.headers['Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.auth.options.username}:${this.config_entity[baseEntity].connection.auth.options.password}`).toString('base64')
277
- break
278
- case 'oauth':
279
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
280
- const err = new Error(`auth type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
281
- throw err
282
- }
283
- param.accessToken = await this.getAccessToken(baseEntity, ctx)
284
- param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
285
- break
286
- case 'token':
287
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
288
- const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
289
- throw err
290
- }
291
- param.accessToken = await this.getAccessToken(baseEntity, ctx)
292
- param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
293
- break
294
- case 'bearer':
295
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.token) {
296
- const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.token`)
297
- throw err
298
- }
299
- param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
300
- break
301
- case 'oauthSamlAssertion':
302
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.companyId
303
- || !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key) {
304
- const err = new Error(`auth type 'oauthSamlAssertion' - missing configuration entity.${baseEntity}.connection.auth.options...`)
305
- throw err
306
- }
307
- param.accessToken = await this.getAccessToken(baseEntity, ctx)
308
- param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
309
- break
310
- default:
311
- // no auth
312
- }
236
+ // basicAuth, bearerAuth, oauth, tokenAuth, oauthSamlAssertion and auth PassTrough using request header authorization
237
+ switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
238
+ case 'basic':
239
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
240
+ const err = new Error(`auth type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
241
+ throw err
242
+ }
243
+ param.options.headers['Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.auth.options.username}:${this.config_entity[baseEntity].connection.auth.options.password}`).toString('base64')
244
+ break
245
+ case 'oauth':
246
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
247
+ const err = new Error(`auth type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
248
+ throw err
249
+ }
250
+ param.accessToken = await this.getAccessToken(baseEntity, ctx)
251
+ param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
252
+ break
253
+ case 'token':
254
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
255
+ const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
256
+ throw err
257
+ }
258
+ param.accessToken = await this.getAccessToken(baseEntity, ctx)
259
+ param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
260
+ break
261
+ case 'bearer':
262
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.token) {
263
+ const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.token`)
264
+ throw err
265
+ }
266
+ param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
267
+ break
268
+ case 'oauthSamlAssertion':
269
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.companyId
270
+ || !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key) {
271
+ const err = new Error(`auth type 'oauthSamlAssertion' - missing configuration entity.${baseEntity}.connection.auth.options...`)
272
+ throw err
273
+ }
274
+ param.accessToken = await this.getAccessToken(baseEntity, ctx)
275
+ param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
276
+ break
277
+ default:
278
+ // no auth or PassTrough
313
279
  }
314
280
 
315
281
  // proxy
@@ -345,19 +311,21 @@ export class HelperRest {
345
311
  }
346
312
 
347
313
  if (!this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = {}
348
- if (!this._serviceClient[baseEntity][clientIdentifier]) this._serviceClient[baseEntity][clientIdentifier] = {}
349
- this._serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
314
+ this._serviceClient[baseEntity] = param // serviceClient created
350
315
 
351
- // OData support - note, not using [clientIdentifier]
316
+ // OData support
352
317
  this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
353
318
  this._serviceClient[baseEntity].nextLink.users = null
354
319
  this._serviceClient[baseEntity].nextLink.groups = null
355
320
  }
356
321
 
357
- const cli: any = utils.copyObj(this._serviceClient[baseEntity][clientIdentifier]) // client ready
322
+ if (ctx?.headers?.get) { // Auth PassThrough using ctx header
323
+ this._serviceClient[baseEntity].options.headers['Authorization'] = ctx.headers.get('authorization')
324
+ }
325
+ const cli: any = utils.copyObj(this._serviceClient[baseEntity]) // client ready
358
326
 
359
327
  // failover support
360
- path = this._serviceClient[baseEntity][clientIdentifier].baseUrl + path
328
+ path = this._serviceClient[baseEntity].baseUrl + path
361
329
  urlObj = new URL(path)
362
330
  cli.options.host = urlObj.hostname
363
331
  cli.options.port = urlObj.port
@@ -413,11 +381,10 @@ export class HelperRest {
413
381
  /**
414
382
  * updateServiceClient merges obj with _serviceClient
415
383
  * @param baseEntity
416
- * @param clientIdentifier
417
384
  * @param obj
418
385
  */
419
- private updateServiceClient(baseEntity: string, clientIdentifier: string, obj: any) {
420
- if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity][clientIdentifier]) this._serviceClient[baseEntity][clientIdentifier] = utils.extendObj(this._serviceClient[baseEntity][clientIdentifier], obj)
386
+ private updateServiceClient(baseEntity: string, obj: any) {
387
+ if (this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = utils.extendObj(this._serviceClient[baseEntity], obj)
421
388
  }
422
389
 
423
390
  /**
@@ -517,17 +484,16 @@ export class HelperRest {
517
484
  } catch (err: any) { // includes failover/retry logic based on config baseUrls array
518
485
  let statusCode
519
486
  try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
520
- if (statusCode === 404) { // not logged as error, let caller decide e.g. getUser-manager
521
- this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
522
- } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
523
- const clientIdentifier = this.getClientIdentifier(ctx)
524
487
  if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
525
488
  if (!retryAfter) retryAfter = 60
526
489
  }
527
490
  if (!retryCount) retryCount = 0
528
491
  let urlObj
529
492
  try { urlObj = new URL(path) } catch (err) { void 0 }
530
- if (!urlObj && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || retryAfter)) {
493
+ let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
494
+ let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
495
+ if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || oAuthTokeErr || retryAfter)) {
496
+ this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
531
497
  if (retryAfter) {
532
498
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} throttle/ratelimit error - awaiting ${retryAfter} seconds before automatic retry`)
533
499
  await new Promise(resolve => setTimeout(function () {
@@ -536,18 +502,25 @@ export class HelperRest {
536
502
  }
537
503
  if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
538
504
  retryCount++
539
- this.updateServiceClient(baseEntity, clientIdentifier, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
505
+ this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
540
506
  this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
507
+ if (oAuthTokeErr) {
508
+ delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
509
+ }
541
510
  const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
542
511
  return ret // problem fixed
543
512
  } else {
513
+ if (statusCode === 404) { // not logged as error e.g. getUser-manager
514
+ this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
515
+ } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
544
516
  throw err
545
517
  }
546
518
  } else {
547
- if (statusCode === 401 && this._serviceClient[baseEntity]) {
548
- delete this._serviceClient[baseEntity][clientIdentifier]
549
- }
550
- throw err // CA IM retries getUser failure once (retry 6 times on ECONNREFUSED)
519
+ if (statusCode === 404) { // not logged as error e.g. getUser-manager
520
+ this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
521
+ } else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
522
+ if (statusCode === 401) delete this._serviceClient[baseEntity]
523
+ throw err
551
524
  }
552
525
  }
553
526
  }
@@ -698,7 +698,6 @@ export class ScimGateway {
698
698
  }
699
699
  if (tokenObj.baseEntities) {
700
700
  if (Array.isArray(tokenObj.baseEntities) && tokenObj.baseEntities.length > 0) {
701
- if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
702
701
  if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
703
702
  }
704
703
  }
@@ -706,7 +705,11 @@ export class ScimGateway {
706
705
  return resolve(true)
707
706
  } else {
708
707
  for (let i = 0; i < arr.length; i++) { // resolve if token memory store have been cleared because of a gateway restart
709
- if (utils.getEncrypted(authToken, arr[i].client_secret) === arr[i].client_secret && !arr[i].isTokenRequested) {
708
+ if (arr[i].isTokenRequested || !arr[i].clientSecret) continue
709
+ if (arr[i].baseEntities && Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
710
+ if (!arr[i].baseEntities.includes(baseEntity)) continue
711
+ }
712
+ if (utils.getEncrypted(authToken, arr[i].clientSecret) === arr[i].clientSecret) {
710
713
  arr[i].isTokenRequested = true // flagged as true to not allow repeated resolvements because token will also be cleared when expired
711
714
  const baseEntities = utils.copyObj(arr[i].baseEntities)
712
715
  let expires
@@ -774,8 +777,21 @@ export class ScimGateway {
774
777
  if (ctx.request.url !== '/favicon.ico') logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
775
778
  return false
776
779
  } catch (err: any) {
777
- if (authType === 'Bearer') ctx.response.headers.set('WWW-Authenticate', 'Bearer realm=""')
778
- else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
780
+ if (authType === 'Bearer') {
781
+ let str = 'realm=""'
782
+ if (err?.name === 'invalid_token') {
783
+ str += `, error="${err.name}"`
784
+ if (err.message) {
785
+ str += `, error_description="${err.message}"`
786
+ const errMsg = {
787
+ error: err.name,
788
+ error_description: err.message,
789
+ }
790
+ ctx.response.body = JSON.stringify(errMsg)
791
+ }
792
+ }
793
+ ctx.response.headers.set('WWW-Authenticate', `Bearer ${str}`)
794
+ } else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
779
795
  if (pwErrCount < 3) {
780
796
  pwErrCount += 1
781
797
  logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message}`)
@@ -826,21 +842,16 @@ export class ScimGateway {
826
842
  ctx.response.status = 500
827
843
  return
828
844
  }
829
-
830
845
  let jsonBody = ctx.request.body
831
846
  try {
832
847
  if (!jsonBody) throw new Error('missing body')
833
- if (typeof jsonBody !== 'object') { // might have application/x-www-form-urlencoded body, but incorrect Content-Type header
848
+ if (typeof jsonBody !== 'object') { // might have application/x-www-form-urlencoded or multipart/form-data body, but incorrect Content-Type header
834
849
  logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] continue request validation even though incorrect body vs header Content-Type: ${ctx.request.headers.get('content-type')}`)
835
- const arr = jsonBody.split('&')
836
- const body: Record<string, any> = {}
837
- arr.forEach((kv: string) => {
838
- const a = kv.split('=')
839
- if (a.length === 2) {
840
- body[a[0]] = decodeURIComponent(a[1])
841
- }
842
- })
843
- if (Object.keys(body).length < 1) throw new Error('body is not JSON nor application/x-www-form-urlencoded')
850
+ let body = utils.formUrlEncodedToJSON(jsonBody)
851
+ if (Object.keys(body).length < 1) {
852
+ body = utils.formDataMultipartToJSON(jsonBody)
853
+ if (Object.keys(body).length < 1) throw new Error('body is not JSON, application/x-www-form-urlencoded nor multipart/form-data')
854
+ }
844
855
  ctx.request.body = body // now json - ensure final info log will be masked
845
856
  jsonBody = body
846
857
  }
@@ -2294,14 +2305,9 @@ export class ScimGateway {
2294
2305
  } catch (err: any) {
2295
2306
  const contentType = request.headers.get('content-type')
2296
2307
  if (contentType && contentType.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
2297
- const arr = bodyString.split('&') // "grant_type=client_credentials&client_id=id&client_secret=secret"
2298
- body = {}
2299
- arr.forEach((kv: string) => {
2300
- const a = kv.split('=')
2301
- if (a.length === 2) {
2302
- body[a[0]] = decodeURIComponent(a[1])
2303
- }
2304
- })
2308
+ body = utils.formUrlEncodedToJSON(bodyString)
2309
+ } else if (contentType && contentType.toLowerCase().startsWith('multipart/form-data')) {
2310
+ body = utils.formDataMultipartToJSON(bodyString)
2305
2311
  } else if (bodyString) body = bodyString
2306
2312
  }
2307
2313
 
package/lib/utils.ts CHANGED
@@ -14,18 +14,20 @@ import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import { EventEmitter } from 'node:events'
16
16
 
17
- // mutual exclusion ref: https://thecodebarbarian.com/mutual-exclusion-patterns-with-node-promises
17
+ /** Lock implements mutual exclusion
18
+ * reference: https://thecodebarbarian.com/mutual-exclusion-patterns-with-node-promises
19
+ */
18
20
  export class Lock {
19
- private _locked: boolean
21
+ private _locked = false
20
22
  private _ee: any
21
23
  constructor() {
22
24
  this._locked = false
23
25
  this._ee = new EventEmitter()
24
26
  }
25
27
 
28
+ /** If nobody has the lock, take it and resolve immediately else wait until released */
26
29
  acquire() {
27
30
  return new Promise((resolve) => {
28
- // If nobody has the lock, take it and resolve immediately
29
31
  if (!this._locked) {
30
32
  // Safe because JS doesn't interrupt you on synchronous operations,
31
33
  // so no need for compare-and-swap or anything like that.
@@ -45,11 +47,16 @@ export class Lock {
45
47
  })
46
48
  }
47
49
 
50
+ /** Release the lock immediately */
48
51
  release() {
49
- // Release the lock immediately
50
52
  this._locked = false
51
53
  setImmediate(() => this._ee.emit('release'))
52
54
  }
55
+
56
+ /** Return status of lock true/false */
57
+ isLocked(): boolean {
58
+ return this._locked
59
+ }
53
60
  }
54
61
 
55
62
  export const getSecret = function (dotNotationAttr: string, configFile: string) {
@@ -606,3 +613,53 @@ export const createRandomPassword = function (len: number, ...set: string[]) {
606
613
  }
607
614
  return res
608
615
  }
616
+
617
+ /**
618
+ * formUrlEncodedToJSON converts application/x-www-form-urlencoded request body to json
619
+ * @param body http request body string
620
+ * @returns json formatted body
621
+ */
622
+ export const formUrlEncodedToJSON = function (body?: string): Record<string, any> {
623
+ if (!body) return {}
624
+ const arr = body.split('&') // "grant_type=client_credentials&client_id=id&client_secret=secret"
625
+ const json: Record<string, any> = {}
626
+ for (const kv of arr) {
627
+ const a = kv.split('=')
628
+ if (a.length === 2) {
629
+ try {
630
+ json[a[0]] = decodeURIComponent(a[1])
631
+ } catch (err) { return {} }
632
+ }
633
+ }
634
+ return json
635
+ }
636
+
637
+ /**
638
+ * formDataMultipartToJSON converts multipart/form-data request body to json - Content-Type: multipart/form-data;boundary="<some-delimiter>"
639
+ * @param body request body
640
+ * @param boundary the boundary/delimiter defiend in header Content-Type: multipart/form-data;boundary="<some-delimiter>"
641
+ * @returns json formatted body
642
+ */
643
+ export const formDataMultipartToJSON = function (body?: string, boundary?: string): Record<string, any> {
644
+ if (!body) return {} // --delimiter123\nContent-Disposition: form-data; name="field1"\n\nvalue1\n--delimiter123\nContent-Disposition: form-data; name="field2"; filename="example.txt"\n\nvalue2
645
+ if (!boundary) { // if boundary is missing, try to infer it
646
+ const inferredBoundary = body.match(/^--([^\r\n]+)/)
647
+ if (inferredBoundary) {
648
+ boundary = inferredBoundary[1]
649
+ } else return {} // No boundary found
650
+ }
651
+ const parts = body.split(`--${boundary}`).filter(part => part.trim() && !part.includes('--'))
652
+ const json: Record<string, any> = {}
653
+ for (const part of parts) {
654
+ const [headers, value] = part.split(/\r?\n\r?\n/)
655
+ const nameMatch = headers.match(/name="([^"]+)"/)
656
+ if (nameMatch) {
657
+ const key = nameMatch[1]
658
+ json[key] = value.trim()
659
+ try {
660
+ json[key] = decodeURIComponent(json[key])
661
+ } catch (err) { return {} }
662
+ }
663
+ }
664
+ return json
665
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.10",
3
+ "version": "5.0.12",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",
@@ -41,19 +41,19 @@
41
41
  "@types/tedious": "^4.0.14",
42
42
  "dot-object": "^2.1.5",
43
43
  "fold-to-ascii": "^5.0.1",
44
- "https-proxy-agent": "^7.0.4",
44
+ "https-proxy-agent": "^7.0.6",
45
45
  "is-in-subnet": "^4.0.1",
46
46
  "jsonwebtoken": "^9.0.2",
47
47
  "ldapjs": "^3.0.7",
48
48
  "lokijs": "^1.5.12",
49
- "mongodb": "^6.6.2",
49
+ "mongodb": "^6.12.0",
50
50
  "nats": "^2.28.2",
51
51
  "node-machine-id": "1.1.12",
52
- "nodemailer": "^6.9.13",
52
+ "nodemailer": "^6.9.16",
53
53
  "passport": "^0.7.0",
54
54
  "passport-azure-ad": "^4.3.5",
55
55
  "saml": "^3.0.1",
56
- "winston": "^3.13.0"
56
+ "winston": "^3.17.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "typescript": "^5.0.0"