scimgateway 5.0.11 → 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 +12 -1
- package/bun.lockb +0 -0
- package/lib/helper-rest.ts +70 -101
- package/lib/scimgateway.ts +9 -19
- package/lib/utils.ts +50 -0
- package/package.json +5 -5
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,17 @@ 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
|
+
|
|
1114
1125
|
### v5.0.11
|
|
1115
1126
|
|
|
1116
1127
|
[Fixed]
|
package/bun.lockb
CHANGED
|
Binary file
|
package/lib/helper-rest.ts
CHANGED
|
@@ -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]
|
|
82
|
-
&& (this._serviceClient[baseEntity]
|
|
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]
|
|
58
|
+
return this._serviceClient[baseEntity].accessToken
|
|
85
59
|
}
|
|
86
60
|
|
|
87
61
|
const action = 'getAccessToken'
|
|
@@ -213,20 +187,19 @@ export class HelperRest {
|
|
|
213
187
|
//
|
|
214
188
|
// path (no url) - default approach and client will be cached based on config
|
|
215
189
|
//
|
|
216
|
-
|
|
217
|
-
if (this._serviceClient[baseEntity] && this._serviceClient[baseEntity][clientIdentifier]) { // serviceClient already exist - token specific
|
|
190
|
+
if (this._serviceClient[baseEntity]) { // serviceClient already exist - token specific
|
|
218
191
|
this.scimgateway.logDebug(baseEntity, `${action}: Using existing client`)
|
|
219
|
-
if (this._serviceClient[baseEntity]
|
|
192
|
+
if (this._serviceClient[baseEntity].accessToken) {
|
|
220
193
|
// check if token refresh is needed when using oauth
|
|
221
194
|
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
|
|
222
|
-
if (this._serviceClient[baseEntity]
|
|
223
|
-
this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity]
|
|
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`)
|
|
224
197
|
try {
|
|
225
198
|
const accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
226
|
-
this._serviceClient[baseEntity]
|
|
227
|
-
this._serviceClient[baseEntity]
|
|
199
|
+
this._serviceClient[baseEntity].accessToken = accessToken
|
|
200
|
+
this._serviceClient[baseEntity].options.headers['Authorization'] = ` Bearer ${accessToken.access_token}`
|
|
228
201
|
} catch (err) {
|
|
229
|
-
|
|
202
|
+
delete this._serviceClient[baseEntity]
|
|
230
203
|
const newErr = err
|
|
231
204
|
throw newErr
|
|
232
205
|
}
|
|
@@ -260,53 +233,49 @@ export class HelperRest {
|
|
|
260
233
|
}
|
|
261
234
|
|
|
262
235
|
// Supporting no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
|
|
263
|
-
// basicAuth, bearerAuth, oauth, tokenAuth and auth PassTrough using request header authorization
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
break
|
|
307
|
-
default:
|
|
308
|
-
// no auth
|
|
309
|
-
}
|
|
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
|
|
310
279
|
}
|
|
311
280
|
|
|
312
281
|
// proxy
|
|
@@ -342,19 +311,21 @@ export class HelperRest {
|
|
|
342
311
|
}
|
|
343
312
|
|
|
344
313
|
if (!this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = {}
|
|
345
|
-
|
|
346
|
-
this._serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
|
|
314
|
+
this._serviceClient[baseEntity] = param // serviceClient created
|
|
347
315
|
|
|
348
|
-
// OData support
|
|
316
|
+
// OData support
|
|
349
317
|
this._serviceClient[baseEntity].nextLink = {} // OData pagination (Entra ID)
|
|
350
318
|
this._serviceClient[baseEntity].nextLink.users = null
|
|
351
319
|
this._serviceClient[baseEntity].nextLink.groups = null
|
|
352
320
|
}
|
|
353
321
|
|
|
354
|
-
|
|
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
|
|
355
326
|
|
|
356
327
|
// failover support
|
|
357
|
-
path = this._serviceClient[baseEntity]
|
|
328
|
+
path = this._serviceClient[baseEntity].baseUrl + path
|
|
358
329
|
urlObj = new URL(path)
|
|
359
330
|
cli.options.host = urlObj.hostname
|
|
360
331
|
cli.options.port = urlObj.port
|
|
@@ -410,11 +381,10 @@ export class HelperRest {
|
|
|
410
381
|
/**
|
|
411
382
|
* updateServiceClient merges obj with _serviceClient
|
|
412
383
|
* @param baseEntity
|
|
413
|
-
* @param clientIdentifier
|
|
414
384
|
* @param obj
|
|
415
385
|
*/
|
|
416
|
-
private updateServiceClient(baseEntity: string,
|
|
417
|
-
if (this._serviceClient[baseEntity]
|
|
386
|
+
private updateServiceClient(baseEntity: string, obj: any) {
|
|
387
|
+
if (this._serviceClient[baseEntity]) this._serviceClient[baseEntity] = utils.extendObj(this._serviceClient[baseEntity], obj)
|
|
418
388
|
}
|
|
419
389
|
|
|
420
390
|
/**
|
|
@@ -514,14 +484,13 @@ export class HelperRest {
|
|
|
514
484
|
} catch (err: any) { // includes failover/retry logic based on config baseUrls array
|
|
515
485
|
let statusCode
|
|
516
486
|
try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
|
|
517
|
-
const clientIdentifier = this.getClientIdentifier(ctx)
|
|
518
487
|
if (err.message.includes('ratelimit')) { // have seen throttling not follow standard 429/retry-after, but instead using 500 and error message only
|
|
519
488
|
if (!retryAfter) retryAfter = 60
|
|
520
489
|
}
|
|
521
490
|
if (!retryCount) retryCount = 0
|
|
522
491
|
let urlObj
|
|
523
492
|
try { urlObj = new URL(path) } catch (err) { void 0 }
|
|
524
|
-
let isServiceClient = !urlObj && this._serviceClient[baseEntity] &&
|
|
493
|
+
let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
|
|
525
494
|
let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
|
|
526
495
|
if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || oAuthTokeErr || retryAfter)) {
|
|
527
496
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
@@ -531,27 +500,27 @@ export class HelperRest {
|
|
|
531
500
|
resolve(null)
|
|
532
501
|
}, retryAfter * 1000))
|
|
533
502
|
}
|
|
534
|
-
if (oAuthTokeErr && this._serviceClient[baseEntity]) delete this._serviceClient[baseEntity][clientIdentifier] // 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?
|
|
535
503
|
if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
|
|
536
504
|
retryCount++
|
|
537
|
-
this.updateServiceClient(baseEntity,
|
|
505
|
+
this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
|
|
538
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
|
+
}
|
|
539
510
|
const ret = await this.doRequestHandler(baseEntity, method, path, body, ctx, opt, retryCount) // retry
|
|
540
511
|
return ret // problem fixed
|
|
541
512
|
} else {
|
|
542
513
|
if (statusCode === 404) { // not logged as error e.g. getUser-manager
|
|
543
514
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
544
|
-
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)}
|
|
515
|
+
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
545
516
|
throw err
|
|
546
517
|
}
|
|
547
518
|
} else {
|
|
548
519
|
if (statusCode === 404) { // not logged as error e.g. getUser-manager
|
|
549
520
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
550
521
|
} else this.scimgateway.logError(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
551
|
-
if (statusCode === 401
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
throw err // Symantec IM retries getUser 6 times on ECONNREFUSED
|
|
522
|
+
if (statusCode === 401) delete this._serviceClient[baseEntity]
|
|
523
|
+
throw err
|
|
555
524
|
}
|
|
556
525
|
}
|
|
557
526
|
}
|
package/lib/scimgateway.ts
CHANGED
|
@@ -842,21 +842,16 @@ export class ScimGateway {
|
|
|
842
842
|
ctx.response.status = 500
|
|
843
843
|
return
|
|
844
844
|
}
|
|
845
|
-
|
|
846
845
|
let jsonBody = ctx.request.body
|
|
847
846
|
try {
|
|
848
847
|
if (!jsonBody) throw new Error('missing body')
|
|
849
|
-
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
|
|
850
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')}`)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
body[a[0]] = decodeURIComponent(a[1])
|
|
857
|
-
}
|
|
858
|
-
})
|
|
859
|
-
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
|
+
}
|
|
860
855
|
ctx.request.body = body // now json - ensure final info log will be masked
|
|
861
856
|
jsonBody = body
|
|
862
857
|
}
|
|
@@ -2310,14 +2305,9 @@ export class ScimGateway {
|
|
|
2310
2305
|
} catch (err: any) {
|
|
2311
2306
|
const contentType = request.headers.get('content-type')
|
|
2312
2307
|
if (contentType && contentType.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
const a = kv.split('=')
|
|
2317
|
-
if (a.length === 2) {
|
|
2318
|
-
body[a[0]] = decodeURIComponent(a[1])
|
|
2319
|
-
}
|
|
2320
|
-
})
|
|
2308
|
+
body = utils.formUrlEncodedToJSON(bodyString)
|
|
2309
|
+
} else if (contentType && contentType.toLowerCase().startsWith('multipart/form-data')) {
|
|
2310
|
+
body = utils.formDataMultipartToJSON(bodyString)
|
|
2321
2311
|
} else if (bodyString) body = bodyString
|
|
2322
2312
|
}
|
|
2323
2313
|
|
package/lib/utils.ts
CHANGED
|
@@ -613,3 +613,53 @@ export const createRandomPassword = function (len: number, ...set: string[]) {
|
|
|
613
613
|
}
|
|
614
614
|
return res
|
|
615
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.
|
|
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.
|
|
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.
|
|
49
|
+
"mongodb": "^6.12.0",
|
|
50
50
|
"nats": "^2.28.2",
|
|
51
51
|
"node-machine-id": "1.1.12",
|
|
52
|
-
"nodemailer": "^6.9.
|
|
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.
|
|
56
|
+
"winston": "^3.17.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"typescript": "^5.0.0"
|