scimgateway 5.0.8 → 5.0.10

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
@@ -1111,6 +1111,18 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1111
1111
 
1112
1112
  ## Change log
1113
1113
 
1114
+ ### v5.0.10
1115
+
1116
+ [Improved]
1117
+
1118
+ - OAuth token request now accept missing or invalid Content-Type header
1119
+
1120
+ ### v5.0.9
1121
+
1122
+ [Improved]
1123
+
1124
+ - HelperRest doRequest() now support configuration auth type `oauthSamlAssertion` for OAuth SAML token assertion. Please see code editor method IntelliSense for details
1125
+
1114
1126
  ### v5.0.8
1115
1127
 
1116
1128
  [Fixed]
package/bun.lockb CHANGED
Binary file
@@ -1,13 +1,13 @@
1
1
  import { HttpsProxyAgent } from 'https-proxy-agent'
2
2
  import { URL } from 'url'
3
3
  import { Buffer } from 'node:buffer'
4
+ import { samlAssertion } from './samlAssertion.ts' // prereq: saml
4
5
  import fs from 'node:fs'
5
6
  import querystring from 'querystring'
6
7
  import * as utils from './utils.ts'
7
- // import type { ScimGateway } from 'scimgateway' // comment out for supporting Node.js, using type any and no IntelliSense
8
8
 
9
9
  /**
10
- * HelperRest includes function doRequest() for doing REST calls
10
+ * HelperRest includes function doRequest() for executing REST calls
11
11
  */
12
12
  export class HelperRest {
13
13
  private lock = new utils.Lock()
@@ -118,6 +118,31 @@ export class HelperRest {
118
118
  }
119
119
  break
120
120
 
121
+ case 'oauthSamlAssertion':
122
+ tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
123
+ const context = null
124
+ const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.cert).toString()
125
+ const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.key).toString()
126
+
127
+ const issuer = `scimgateway/${this.scimgateway.pluginName}`
128
+ const lifetime = 3600
129
+ const clientId = this.config_entity[baseEntity].connection.auth.options.clientId
130
+ const nameId = this.config_entity[baseEntity].connection.auth.options.userId
131
+ const userIdentifierFormat = 'userName'
132
+ const tokenEndpoint = tokenUrl
133
+ const audience = `scimgateway/${this.scimgateway.pluginName}`
134
+ const delay = 1
135
+
136
+ const assertion = await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay)
137
+ form = {
138
+ token_url: tokenUrl,
139
+ grant_type: 'urn:ietf:params:oauth:grant-type:saml2-bearer',
140
+ client_id: clientId,
141
+ company_id: this.config_entity[baseEntity].connection.auth.options.companyId,
142
+ assertion: assertion,
143
+ }
144
+ break
145
+
121
146
  default:
122
147
  this.lock.release()
123
148
  throw new Error(`getAccessToken() none supported entity.${baseEntity}.connection.auth.type: '${this.config_entity[baseEntity]?.connection?.auth?.type}'`)
@@ -176,7 +201,7 @@ export class HelperRest {
176
201
  * @param baseEntity baseEntity
177
202
  * @param method GET/PATCH/PUT/DELETE
178
203
  * @param path e.g., /Users having baseUrl from configuration added, or full url e.g. https://mycompany.com/Users
179
- * @param opt optional, connection optios
204
+ * @param opt optional, connection options
180
205
  * @param ctx optional, ctx included if using Auth PassThrough
181
206
  * @returns client.options needed for connect
182
207
  */
@@ -255,7 +280,7 @@ export class HelperRest {
255
280
  const err = new Error(`auth type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
256
281
  throw err
257
282
  }
258
- param.accessToken = await this.getAccessToken(baseEntity, ctx) // support Auth PassThrough
283
+ param.accessToken = await this.getAccessToken(baseEntity, ctx)
259
284
  param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
260
285
  break
261
286
  case 'token':
@@ -263,7 +288,7 @@ export class HelperRest {
263
288
  const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
264
289
  throw err
265
290
  }
266
- param.accessToken = await this.getAccessToken(baseEntity, ctx) // support Auth PassThrough
291
+ param.accessToken = await this.getAccessToken(baseEntity, ctx)
267
292
  param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
268
293
  break
269
294
  case 'bearer':
@@ -273,6 +298,15 @@ export class HelperRest {
273
298
  }
274
299
  param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
275
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
276
310
  default:
277
311
  // no auth
278
312
  }
@@ -426,6 +460,7 @@ export class HelperRest {
426
460
  const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
427
461
  options.signal = signal
428
462
  const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
463
+ if (path.includes(')?$') && !options.headers['Accept-Encoding']) options.headers['Accept-Encoding'] = 'identity' // workaround for bun fetch error: "Decompression error: ShortRead" - have seen this error using OData with "<some-path>('xxx')?$expand=" or "<some-path>('xxx')?$select=" ref: https://github.com/oven-sh/bun/issues/8017
429
464
  // execute request
430
465
  const f = await fetch(url, options)
431
466
  clearTimeout(timeout)
@@ -534,15 +569,20 @@ export class HelperRest {
534
569
  * "entity": {
535
570
  * "undefined": {
536
571
  * "connection": {
537
- * "baseUrls": [
572
+ * "baseUrls": [ // ignored when using option tenantIdGUID
538
573
  * "<baseUrl>", // "https://host1.company.com:8880",
539
574
  * "<baseUrl2>" // optional using several baseUrls for failover
540
575
  * ],
541
576
  * "auth": {
542
- * "type": "<type>"",
577
+ * "type": "<type>",
543
578
  * "options": { <auth.options> }
544
579
  * },
545
580
  * "options": { <connection.options> }
581
+ * "proxy": {
582
+ * "host": "<host>", // http://proxy-host:1234
583
+ * "username": "<username>", // username if authentication is required
584
+ * "password": "<password>" // password if authentication is required
585
+ * }
546
586
  * }
547
587
  * }
548
588
  * }
@@ -551,11 +591,11 @@ export class HelperRest {
551
591
  * ```
552
592
  * type defines authentication being used
553
593
  * if type not defined, no authentication used
554
- * valid type is: `basic`, `oauth`, `token` or `bearer`
594
+ * valid type is: `basic`, `oauth`, `token`, `bearer` or `oauthSamlAssertion`
555
595
  *
556
596
  * for each valid type there are different auth.options
557
597
  *
558
- * type=**basic**, auth.options:
598
+ * type=**"basic"** having auth.options:
559
599
  * ```
560
600
  * {
561
601
  * "options": {
@@ -565,11 +605,11 @@ export class HelperRest {
565
605
  * }
566
606
  * ```
567
607
  *
568
- * type=**oauth**, auth.options:
608
+ * type=**"oauth"** having auth.options:
569
609
  * ```
570
610
  * {
571
611
  * "options": {
572
- * "tenantIdGUID": "<Entra ID tenantIdGUID", // only defined when using Entra ID
612
+ * "tenantIdGUID": "<Entra ID tenantIdGUID", // simplified configuration for using Microsoft Graph API
573
613
  * "tokenUrl": "<tokenUrl>", // not used when tenantIdGUID defined
574
614
  * "clientId": "<clientId",
575
615
  * "clientSecret": "<clientSecret>"
@@ -577,7 +617,7 @@ export class HelperRest {
577
617
  * }
578
618
  * ```
579
619
  *
580
- * type=**token**, auth.options:
620
+ * type=**"token"** having auth.options:
581
621
  * ```
582
622
  * {
583
623
  * "options": {
@@ -588,7 +628,7 @@ export class HelperRest {
588
628
  * }
589
629
  * ```
590
630
  *
591
- * type=**bearer**, auth.options:
631
+ * type=**"bearer"** having auth.options:
592
632
  * ```
593
633
  * {
594
634
  * "options": {
@@ -597,6 +637,22 @@ export class HelperRest {
597
637
  * }
598
638
  * ```
599
639
  *
640
+ * type=**"oauthSamlAssertion"** having auth.options:
641
+ * ```
642
+ * {
643
+ * "options": {
644
+ * "tokenUrl": "<tokenUrl>",
645
+ * "clientId": "<clientId>",
646
+ * "companyId": "<companyId>",
647
+ * "userId": "<userId>",
648
+ * "certificate": {
649
+ * "key": "<key-file-name>", // location: config/certs
650
+ * "cert": "<cert-file-name>", // location: config/certs
651
+ * }
652
+ * }
653
+ * }
654
+ * ```
655
+ *
600
656
  * **connection.options** can be set according to web-standard fetch client options
601
657
  * examples:
602
658
  * ```
package/lib/logger.ts CHANGED
@@ -96,7 +96,7 @@ export class Log {
96
96
  customMaskXml = customMasking.join('"?|')
97
97
  customMaskXml = '|' + customMaskXml + '"?'
98
98
  }
99
- this.reJson = `^.*"(password|access_token|client_secret${customMaskJson})" ?: ?"([^"]+)".*`
99
+ this.reJson = `^.*"(password|access_token|client_secret|assertion${customMaskJson})" ?: ?"([^"]+)".*`
100
100
  this.reXml = `^.*(credentials"?|PasswordText"?|PasswordDigest"?|password"?${customMaskXml})>([^<]+)</.*`
101
101
 
102
102
  const trans: any = [
@@ -0,0 +1,220 @@
1
+ //
2
+ // Purpose: create SAML token assertion that can be used by OAuth token request having grant type saml2-bearer
3
+ // Based on: https://github.com/edersouza38/insomnia-plugin-sfsf-samlassertion
4
+ //
5
+ // MIT License
6
+ //
7
+ // Copyright (c) 2023 edersouza38
8
+ //
9
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ // of this software and associated documentation files (the "Software"), to deal
11
+ // in the Software without restriction, including without limitation the rights
12
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ // copies of the Software, and to permit persons to whom the Software is
14
+ // furnished to do so, subject to the following conditions:
15
+ //
16
+ // The above copyright notice and this permission notice shall be included in all
17
+ // copies or substantial portions of the Software.
18
+ //
19
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ // SOFTWARE.
26
+ //
27
+
28
+ // @ts-expect-error type declaration file not found
29
+ import { Saml20 as saml } from 'saml'
30
+ import crypto from 'node:crypto'
31
+
32
+ export const samlAssertionUtils = {
33
+ formatPrivateKey: function (input: string) {
34
+ // Validate PEM keys:
35
+ const keyArmor = /-----(BEGIN |END )(.*?) KEY-----/g
36
+ let v: any = [...input.matchAll(keyArmor)]
37
+ if (v.length > 0) {
38
+ if (v.length !== 2 || v[0][2] !== v[1][2] || v[0][2] !== 'PRIVATE') {
39
+ throw new Error('Invalid PEM private key. Make sure that the armoring is consistent and the PEM key is from the type "PRIVATE".')
40
+ }
41
+ return input.replace(/\r?\n|\r/g, '')
42
+ }
43
+
44
+ // Verify whether key was generated directly in SFSF:
45
+ const d = Buffer.from(input, 'base64').toString('utf-8')
46
+ v = d.split('###')
47
+ if (v.length === 2) {
48
+ input = v[0]
49
+ }
50
+ return `-----BEGIN PRIVATE KEY-----${input}-----END PRIVATE KEY-----`
51
+ },
52
+
53
+ formatCertificate: function (input: string) {
54
+ // Validate PEM keys:
55
+ const keyArmor = /-----(BEGIN |END )(.*?)-----/g
56
+ let v: any = [...input.matchAll(keyArmor)]
57
+ if (v.length > 0) {
58
+ if (v.length !== 2 || v[0][2] !== v[1][2]) {
59
+ throw new Error('Invalid PEM certificate. Make sure that the armoring is consistent.')
60
+ }
61
+ return input.replace(/\r?\n|\r/g, '')
62
+ }
63
+
64
+ // Verify whether key was generated directly in SFSF:
65
+ const d = Buffer.from(input, 'base64').toString('utf-8')
66
+ v = d.split('###')
67
+ if (v.length === 2) {
68
+ input = v[0]
69
+ }
70
+ return `-----BEGIN CERTIFICATE-----${input}-----END CERTIFICATE-----`
71
+ },
72
+
73
+ delay: function (time: number) {
74
+ return new Promise(resolve => setTimeout(resolve, time))
75
+ },
76
+
77
+ userIdentifierFormat: {
78
+ userId: 'userId',
79
+ userName: 'userName',
80
+ eMail: 'e-Mail',
81
+ },
82
+ }
83
+
84
+ export const samlAssertion = {
85
+ name: 'samlAssertionSFSF',
86
+ displayName: 'SAML Assertion - SFSF',
87
+ description: 'Create a SAML Assertion for SFSF OAuth2SAMLAssertion flow.',
88
+ args: [
89
+ {
90
+ displayName: 'X.509 Certificate',
91
+ description: 'X.509 Certificate used to identify the SAML IdP',
92
+ type: 'string',
93
+ placeholder: '-----BEGIN CERTIFICATE-----',
94
+ },
95
+ {
96
+ displayName: 'Private Key',
97
+ description: 'Private Key used to sign the SAML Assertion',
98
+ type: 'string',
99
+ placeholder: '-----BEGIN PRIVATE KEY-----',
100
+ },
101
+ {
102
+ displayName: 'SAML Issuer',
103
+ description: 'Name of the IdP issuing the SAML Assertion',
104
+ type: 'string',
105
+ defaultValue: 'local.insomnia.com',
106
+ },
107
+ {
108
+ displayName: 'Lifetime in seconds',
109
+ description: 'Lifetime of the SAML Assertion in seconds',
110
+ type: 'number',
111
+ defaultValue: 600,
112
+ },
113
+ {
114
+ displayName: 'Client Id',
115
+ description: 'Registered Client Id in SFSF',
116
+ type: 'string',
117
+ placeholder: 'OWE1Yzg0NTMyOGJlY2M4NWRiZGFiMGE3MTI5MA',
118
+ },
119
+ {
120
+ displayName: 'User Identifier',
121
+ description: 'User Identifier',
122
+ type: 'string',
123
+ placeholder: 'Username',
124
+ },
125
+ {
126
+ displayName: 'User Identifier Format',
127
+ description: 'User Identifier Format',
128
+ type: 'enum',
129
+ placeholder: 'User Identifier Format',
130
+ defaultValue: samlAssertionUtils.userIdentifierFormat.userId,
131
+ options: [
132
+ {
133
+ displayName: 'User ID',
134
+ value: samlAssertionUtils.userIdentifierFormat.userId,
135
+ },
136
+ {
137
+ displayName: 'Username',
138
+ value: samlAssertionUtils.userIdentifierFormat.userName,
139
+ },
140
+ {
141
+ displayName: 'E-Mail',
142
+ value: samlAssertionUtils.userIdentifierFormat.eMail,
143
+ },
144
+ ],
145
+ },
146
+ {
147
+ displayName: 'OAuth Token Endpoint',
148
+ description: 'SFSF OAuth Token Endpoint',
149
+ type: 'string',
150
+ placeholder: 'Username',
151
+ },
152
+ {
153
+ displayName: 'Audience',
154
+ description: 'Audience of the SAML Assertion',
155
+ type: 'string',
156
+ defaultValue: 'www.successfactors.com',
157
+ },
158
+ {
159
+ displayName: 'Delay (Seconds)',
160
+ description: 'Useful when the request is reaching the endpoint before the "validNotBefore" date from SAML assertion.',
161
+ type: 'number',
162
+ defaultValue: 0,
163
+ },
164
+ ],
165
+
166
+ async run(
167
+ context: any,
168
+ cert: any,
169
+ key: any,
170
+ issuer: any,
171
+ lifetime: any,
172
+ clientId: any,
173
+ nameId: any,
174
+ userIdentifierFormat: any,
175
+ tokenEndpoint: any,
176
+ audience: any,
177
+ delay: any,
178
+ ) {
179
+ const samlAttributes: Record<string, any> = {
180
+ api_key: clientId,
181
+ }
182
+
183
+ let nameIdentifierFormat
184
+ = 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified'
185
+
186
+ switch (userIdentifierFormat) {
187
+ case samlAssertionUtils.userIdentifierFormat.eMail:
188
+ nameIdentifierFormat
189
+ = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
190
+ break
191
+ case samlAssertionUtils.userIdentifierFormat.userName:
192
+ samlAttributes.use_username = 'true'
193
+ break
194
+ default:
195
+ break
196
+ }
197
+ const options = {
198
+ cert: samlAssertionUtils.formatCertificate(cert),
199
+ key: samlAssertionUtils.formatPrivateKey(key),
200
+ issuer: issuer,
201
+ lifetimeInSeconds: lifetime,
202
+ audiences: audience,
203
+ attributes: samlAttributes,
204
+ nameIdentifier: nameId,
205
+ nameIdentifierFormat: nameIdentifierFormat,
206
+ recipient: tokenEndpoint,
207
+ sessionIndex: '_' + crypto.randomUUID(),
208
+ }
209
+
210
+ const assertionBuff = Buffer.from(saml.create(options))
211
+ const assertion = assertionBuff.toString('base64')
212
+
213
+ if (delay > 0) {
214
+ await samlAssertionUtils.delay(delay * 1000)
215
+ }
216
+ return assertion
217
+ },
218
+ }
219
+
220
+ export default samlAssertion
@@ -830,7 +830,20 @@ export class ScimGateway {
830
830
  let jsonBody = ctx.request.body
831
831
  try {
832
832
  if (!jsonBody) throw new Error('missing body')
833
- if (typeof jsonBody !== 'object') throw new Error('body is not JSON')
833
+ if (typeof jsonBody !== 'object') { // might have application/x-www-form-urlencoded body, but incorrect Content-Type header
834
+ 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')
844
+ ctx.request.body = body // now json - ensure final info log will be masked
845
+ jsonBody = body
846
+ }
834
847
  jsonBody = utils.copyObj(jsonBody) // no changes to original
835
848
  } catch (err: any) {
836
849
  logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] token request error: ${err.message}`)
@@ -875,7 +888,7 @@ export class ScimGateway {
875
888
  }
876
889
  if (!token) {
877
890
  err = 'invalid_client'
878
- errDescr = 'incorrect or missing clientId/clientSecret'
891
+ errDescr = 'incorrect or missing client_id/client_secret'
879
892
  if (pwErrCount < 3) {
880
893
  pwErrCount += 1
881
894
  } else { // delay brute force attempts
@@ -1008,7 +1021,6 @@ export class ScimGateway {
1008
1021
  }
1009
1022
  }
1010
1023
 
1011
- userObj = utilsScim.addPrimaryAttrs(userObj)
1012
1024
  scimdata = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
1013
1025
  scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, undefined)
1014
1026
 
@@ -1259,7 +1271,6 @@ export class ScimGateway {
1259
1271
  if (this.config.scimgateway.scim.skipMetaLocation) location = undefined
1260
1272
  else if (ctx.query.attributes || (ctx.query.excludedAttributes && ctx.query.excludedAttributes.includes('meta'))) location = undefined
1261
1273
  for (let i = 0; i < scimdata.Resources.length; i++) {
1262
- scimdata.Resources[i] = utilsScim.addPrimaryAttrs(scimdata.Resources[i])
1263
1274
  scimdata.Resources[i] = utils.stripObj(scimdata.Resources[i], ctx.query.attributes, ctx.query.excludedAttributes)
1264
1275
  }
1265
1276
  scimdata = utilsScim.addResources(scimdata, ctx.query.startIndex, ctx.query.sortBy, ctx.query.sortOrder)
@@ -1440,7 +1451,6 @@ export class ScimGateway {
1440
1451
  ctx.response.headers.set('Location', location)
1441
1452
  }
1442
1453
  delete jsonBody.password
1443
- jsonBody = utilsScim.addPrimaryAttrs(jsonBody)
1444
1454
  jsonBody = utilsScim.addSchemas(jsonBody, isScimv2, handle.description, undefined)
1445
1455
  ctx.response.status = 201
1446
1456
  ctx.response.body = JSON.stringify(jsonBody)
@@ -1658,7 +1668,7 @@ export class ScimGateway {
1658
1668
  const location = ctx.origin + ctx.path
1659
1669
  ctx.response.headers.set('Location', location)
1660
1670
  }
1661
- const userObj = utilsScim.addPrimaryAttrs(scimdata.Resources[0])
1671
+ const userObj = scimdata.Resources[0]
1662
1672
  scimdata = utils.stripObj(userObj, ctx.query.attributes, ctx.query.excludedAttributes)
1663
1673
  scimdata = utilsScim.addSchemas(scimdata, isScimv2, handle.description, undefined)
1664
1674
  ctx.response.status = 200
@@ -2283,13 +2293,13 @@ export class ScimGateway {
2283
2293
  body = JSON.parse(bodyString)
2284
2294
  } catch (err: any) {
2285
2295
  const contentType = request.headers.get('content-type')
2286
- if (contentType && contentType.toLowerCase() === 'application/x-www-form-urlencoded') {
2296
+ if (contentType && contentType.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
2287
2297
  const arr = bodyString.split('&') // "grant_type=client_credentials&client_id=id&client_secret=secret"
2288
2298
  body = {}
2289
2299
  arr.forEach((kv: string) => {
2290
2300
  const a = kv.split('=')
2291
2301
  if (a.length === 2) {
2292
- body[a[0]] = a[1]
2302
+ body[a[0]] = decodeURIComponent(a[1])
2293
2303
  }
2294
2304
  })
2295
2305
  } else if (bodyString) body = bodyString
@@ -2382,9 +2392,9 @@ export class ScimGateway {
2382
2392
  controller.close()
2383
2393
  },
2384
2394
  })
2385
- response = new Response(body ? stream : undefined, { status: ctx.response.status, headers: ctx.response.headers })
2395
+ response = new Response(body ? stream : body, { status: ctx.response.status, headers: ctx.response.headers })
2386
2396
  } else {
2387
- response = new Response(body ? body : undefined, { status: ctx.response.status, headers: ctx.response.headers })
2397
+ response = new Response(body, { status: ctx.response.status, headers: ctx.response.headers })
2388
2398
  }
2389
2399
  logResult(ctx)
2390
2400
  return response
package/lib/utils-scim.ts CHANGED
@@ -793,7 +793,19 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
793
793
  } else if (key === 'password') delete data.Resources[i].password // exclude password, null and empty object/array
794
794
  else if (data.Resources[i][key] === null) delete data.Resources[i][key]
795
795
  else if (JSON.stringify(data.Resources[i][key]) === '{}') delete data.Resources[i][key]
796
- else if (Array.isArray(data.Resources[i][key]) && data.Resources[i][key].length < 1) delete data.Resources[i][key]
796
+ else if (Array.isArray(data.Resources[i][key])) {
797
+ if (data.Resources[i][key].length < 1) delete data.Resources[i][key]
798
+ else if (key !== 'members' && key !== 'groups') { // any primary attribute should be boolean
799
+ for (let j = 0; j < data.Resources[i][key].length; j++) {
800
+ let el = data.Resources[i][key][j]
801
+ if (typeof el !== 'object') break
802
+ if (el.type && el.primary && typeof el.primary === 'string') {
803
+ if (el.primary.toLowerCase() === 'true') el.primary = true
804
+ else if (el.primary.toLowerCase() === 'false') el.primary = false
805
+ }
806
+ }
807
+ }
808
+ }
797
809
  }
798
810
  if (Object.keys(data.Resources[i]).length === 0) {
799
811
  data.Resources.splice(i, 1) // delete
@@ -827,37 +839,25 @@ export function addSchemas(data: Record<string, any>, isScimv2: boolean, type?:
827
839
  } else if (key === 'password') delete data.password // exclude password, null and empty object/array
828
840
  else if (data[key] === null) delete data[key]
829
841
  else if (JSON.stringify(data[key]) === '{}') delete data[key]
830
- else if (Array.isArray(data[key]) && data[key].length < 1) delete data[key]
842
+ else if (Array.isArray(data[key])) {
843
+ if (data[key].length < 1) delete data[key]
844
+ else if (key !== 'members' && key !== 'groups') { // any primary attribute should be boolean
845
+ for (let j = 0; j < data[key].length; j++) {
846
+ let el = data[key][j]
847
+ if (typeof el !== 'object') break
848
+ if (el.type && el.primary && typeof el.primary === 'string') {
849
+ if (el.primary.toLowerCase() === 'true') el.primary = true
850
+ else if (el.primary.toLowerCase() === 'false') el.primary = false
851
+ }
852
+ }
853
+ }
854
+ }
831
855
  }
832
856
  }
833
857
 
834
858
  return data
835
859
  }
836
860
 
837
- // addPrimaryAttrs cheks for primary attributes (only for roles) and add them as standalone attributes
838
- // some IdP's may check for these e.g. Azure
839
- // e.g. {roles: [{value: "val1", primary: "True"}]}
840
- // gives:
841
- // { roles: [{value: "val1", primary: "True"}],
842
- // roles[primary eq "True"].value: "val1",
843
- // roles[primary eq "True"].primary: "True"}]
844
- // }
845
- export function addPrimaryAttrs(obj: Record<string, any>) {
846
- const key = 'roles'
847
- if (!obj || typeof obj !== 'object') return obj
848
- if (!obj[key] || !Array.isArray(obj[key])) return obj
849
- const o = utils.copyObj(obj)
850
- const index = o[key].findIndex((el: Record<string, any>) => (el.primary === true || (typeof el.primary === 'string' && el.primary.toLowerCase() === 'true')))
851
- if (index >= 0) {
852
- const prim = o[key][index]
853
- for (const k in prim) {
854
- const primKey = `${key}[primary eq ${typeof prim.primary === 'string' ? `"${prim.primary}"` : prim.primary}].${k}` // roles[primary eq true].value / roles[primary eq "True"].value``
855
- o[primKey] = prim[k] // { roles[primary eq true].value : "some-value" }
856
- }
857
- }
858
- return o
859
- }
860
-
861
861
  //
862
862
  // SCIM error formatting
863
863
  //
@@ -900,7 +900,7 @@ export function jsonErr(scimVersion: string | number, pluginName: string, htmlEr
900
900
 
901
901
  if (scimVersion !== '2.0' && scimVersion !== 2) { // v1.1
902
902
  errJson
903
- = {
903
+ = {
904
904
  Errors: [
905
905
  {
906
906
  description: msg,
@@ -910,7 +910,7 @@ export function jsonErr(scimVersion: string | number, pluginName: string, htmlEr
910
910
  }
911
911
  } else { // v2.0
912
912
  errJson
913
- = {
913
+ = {
914
914
  schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'],
915
915
  scimType,
916
916
  detail: msg,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.8",
3
+ "version": "5.0.10",
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)",
@@ -52,6 +52,7 @@
52
52
  "nodemailer": "^6.9.13",
53
53
  "passport": "^0.7.0",
54
54
  "passport-azure-ad": "^4.3.5",
55
+ "saml": "^3.0.1",
55
56
  "winston": "^3.13.0"
56
57
  },
57
58
  "peerDependencies": {