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 +19 -1
- package/bun.lockb +0 -0
- package/lib/helper-rest.ts +78 -105
- package/lib/scimgateway.ts +29 -23
- package/lib/utils.ts +61 -4
- 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,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
|
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'
|
|
@@ -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
|
-
|
|
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]
|
|
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]
|
|
226
|
-
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`)
|
|
227
197
|
try {
|
|
228
198
|
const accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
229
|
-
this._serviceClient[baseEntity]
|
|
230
|
-
this._serviceClient[baseEntity]
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
349
|
-
this._serviceClient[baseEntity][clientIdentifier] = param // serviceClient created
|
|
314
|
+
this._serviceClient[baseEntity] = param // serviceClient created
|
|
350
315
|
|
|
351
|
-
// OData support
|
|
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
|
-
|
|
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]
|
|
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,
|
|
420
|
-
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)
|
|
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
|
-
|
|
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,
|
|
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 ===
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
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
|
}
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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 (
|
|
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')
|
|
778
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|