scimgateway 5.0.8 → 5.0.9

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,12 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1111
1111
 
1112
1112
  ## Change log
1113
1113
 
1114
+ ### v5.0.9
1115
+
1116
+ [Improved]
1117
+
1118
+ - HelperRest doRequest() now support configuration auth type `oauthSamlAssertion` for OAuth SAML token assertion. Please see code editor method IntelliSense for details
1119
+
1114
1120
  ### v5.0.8
1115
1121
 
1116
1122
  [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
@@ -2382,9 +2382,9 @@ export class ScimGateway {
2382
2382
  controller.close()
2383
2383
  },
2384
2384
  })
2385
- response = new Response(body ? stream : undefined, { status: ctx.response.status, headers: ctx.response.headers })
2385
+ response = new Response(body ? stream : body, { status: ctx.response.status, headers: ctx.response.headers })
2386
2386
  } else {
2387
- response = new Response(body ? body : undefined, { status: ctx.response.status, headers: ctx.response.headers })
2387
+ response = new Response(body, { status: ctx.response.status, headers: ctx.response.headers })
2388
2388
  }
2389
2389
  logResult(ctx)
2390
2390
  return response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.8",
3
+ "version": "5.0.9",
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": {