scimgateway 5.0.7 → 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
@@ -258,8 +258,8 @@ Below shows an example of config\plugin-saphana.json
258
258
  ],
259
259
  "bearerOAuth": [
260
260
  {
261
- "client_id": null,
262
- "client_secret": null,
261
+ "clientId": null,
262
+ "clientSecret": null,
263
263
  "readOnly": false,
264
264
  "baseEntities": []
265
265
  }
@@ -398,7 +398,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
398
398
 
399
399
  - **auth.bearerJwt** - Array of one or more standard JWT objects. Using **secret** or **publicKey** for signature verification. publicKey should be set to the filename of public key or certificate pem-file located in `<package-root>\config\certs` or absolute path being used. Clear text secret will become encrypted when gateway is started. **options.issuer** is mandatory. Other options may also be included according to jsonwebtoken npm package definition.
400
400
 
401
- - **auth.bearerOAuth** - Array of one or more Client Credentials OAuth configuration objects. **`client_id`** and **`client_secret`** are mandatory. client_secret value will become encrypted when gateway is started. OAuth token request url is **/oauth/token** e.g. http://localhost:8880/oauth/token
401
+ - **auth.bearerOAuth** - Array of one or more Client Credentials OAuth configuration objects. **`clientId`** and **`clientSecret`** are mandatory. clientSecret value will become encrypted when gateway is started. OAuth token request url is **/oauth/token** e.g. http://localhost:8880/oauth/token
402
402
 
403
403
  - **auth.passThrough** - Setting **auth.passThrough.enabled=true** will bypass SCIM Gateway authentication. Gateway will instead pass ctx containing authentication header to the plugin. Plugin could then use this information for endpoint authentication and we don't have any password/token stored at the gateway. Note, this also requires plugin binary having `scimgateway.authPassThroughAllowed = true` and endpoint logic for handling/passing ctx.request.header.authorization
404
404
 
@@ -462,18 +462,18 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
462
462
 
463
463
  Configuration notes when using default configuration oauth and tenantIdGUID - Microsoft Exchange Online (ExO):
464
464
 
465
- - Entra ID application must have application permissions "**Mail.Send**"
466
- - To prevent the sending of emails from any defined mailboxes, an ExO **ApplicationAccessPolicy** must be defined through PowerShell.
465
+ - Entra ID application must have application permissions `Mail.Send`
466
+ - To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
467
467
 
468
468
  First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
469
- Note, "mail enabled security" group cannot be created from portal, only from admin or admin.exchange console
469
+ Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
470
470
 
471
471
  ##Connect to Exchange
472
472
  Install-Module -Name ExchangeOnlineManagement
473
473
  Connect-ExchangeOnline
474
474
 
475
475
  ##Create ApplicationAccessPolicy
476
- New-ApplicationAccessPolicy -AppId $AppClientID -PolicyScopeGroupId $MailEnabledSecurityGrpId -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
476
+ New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
477
477
 
478
478
  - **stream** - See [SCIM Stream](https://elshaug.xyz/docs/scim-stream) for configuration details
479
479
 
@@ -1111,6 +1111,21 @@ 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
+
1120
+ ### v5.0.8
1121
+
1122
+ [Fixed]
1123
+
1124
+ - Ensure Bun compatibility with Azure Reverse Proxy for large and long running response
1125
+ - HelperRest was not compatible with Node.js
1126
+ - plugin-mssql, some error handling should not throw an error
1127
+ - Configuration files updated according to the v5 configuration syntax of `scimgateway.auth.bearerOAuth` - `clientId/clientSecret` now replacing deprecated `client_id/client_secret`
1128
+
1114
1129
  ### v5.0.7
1115
1130
 
1116
1131
  [Improved]
@@ -1155,9 +1170,7 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1155
1170
 
1156
1171
 
1157
1172
  **new configuration:**
1158
- Using Microsoft Exchange Online and oauth authencation which also is default and recommended by Microsoft
1159
- For other mail servers and options like SMTP AUTH (basic/oauth), please see configuration description
1160
- Plugin may also send mail using method scimgateway.sendMail()
1173
+ Using Microsoft Exchange Online and oauth authencation which also is default and recommended by Microsoft. For other mail servers and options like SMTP AUTH (basic/oauth), please see configuration description. Plugin may also send mail using method scimgateway.sendMail()
1161
1174
 
1162
1175
  {
1163
1176
  "scimgateway": {
@@ -1184,18 +1197,18 @@ Plugin may also send mail using method scimgateway.sendMail()
1184
1197
 
1185
1198
  Configuration notes when using oauth and tenantIdGUID - Microsoft Exchange Online (ExO):
1186
1199
 
1187
- - Entra ID application must have application permissions "**Mail.Send**"
1188
- - To prevent the sending of emails from any defined mailboxes, an ExO **ApplicationAccessPolicy** must be defined through PowerShell.
1200
+ - Entra ID application must have application permissions `Mail.Send`
1201
+ - To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
1189
1202
 
1190
1203
  First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
1191
- Note, "mail enabled security" group cannot be created from portal, only from admin or admin.exchange console
1204
+ Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
1192
1205
 
1193
1206
  ##Connect to Exchange
1194
1207
  Install-Module -Name ExchangeOnlineManagement
1195
1208
  Connect-ExchangeOnline
1196
1209
 
1197
1210
  ##Create ApplicationAccessPolicy
1198
- New-ApplicationAccessPolicy -AppId $AppClientID -PolicyScopeGroupId $MailEnabledSecurityGrpId -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
1211
+ New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
1199
1212
 
1200
1213
 
1201
1214
  ### v5.0.5
@@ -1308,7 +1321,7 @@ Besides going from JavaScript to TypeScript, following can be mentioned:
1308
1321
 
1309
1322
  * Use scimgateway.HelperRest() for REST functionlity, also supports Auth PassThrough
1310
1323
  * scimgateway.endpointMapper() may be used for inbound/outbound attribute mappings
1311
- * In general when using TypeScript, variables should be type defined: `let isDone: boolean = false`, `catch (err: any)`, ...
1324
+ * In general when using TypeScript, variables should be type-defined: `let isDone: boolean = false`, `catch (err: any)`, ...
1312
1325
 
1313
1326
  ### v4.5.12
1314
1327
 
package/bun.lockb CHANGED
Binary file
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -58,8 +58,8 @@
58
58
  ],
59
59
  "bearerOAuth": [
60
60
  {
61
- "client_id": null,
62
- "client_secret": null,
61
+ "clientId": null,
62
+ "clientSecret": null,
63
63
  "readOnly": false,
64
64
  "baseEntities": []
65
65
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -52,8 +52,8 @@
52
52
  ],
53
53
  "bearerOAuth": [
54
54
  {
55
- "client_id": null,
56
- "client_secret": null,
55
+ "clientId": null,
56
+ "clientSecret": null,
57
57
  "readOnly": false,
58
58
  "baseEntities": []
59
59
  }
@@ -1,24 +1,24 @@
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 ScimGateway from 'scimgateway'
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()
14
14
  private _serviceClient: Record<string, any> = {}
15
15
  private config_entity: any
16
- private scimgateway: ScimGateway
16
+ private scimgateway: any
17
17
  private idleTimeout: number
18
18
  private graphUrl = 'https://graph.microsoft.com/beta' // beta instead of 'v1.0' gives all user attributes when no $select
19
19
 
20
- constructor(scimgateway: ScimGateway, optionalEntities?: Record<string, any>) {
21
- if (!(scimgateway instanceof ScimGateway)) throw new Error('HelperRest initialization error: argument scimgateway is not of type ScimGateway')
20
+ constructor(scimgateway: any, optionalEntities?: Record<string, any>) {
21
+ if (!scimgateway || !scimgateway.gwName) throw new Error('HelperRest initialization error: argument scimgateway is not of type ScimGateway')
22
22
  this.scimgateway = scimgateway
23
23
  this.idleTimeout = (scimgateway as any)?.config?.scimgateway.idleTimeout || 120
24
24
  this.idleTimeout = this.idleTimeout - 1
@@ -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 = [
@@ -6,7 +6,7 @@
6
6
  // Purpose: SQL user-provisioning
7
7
  //
8
8
  // Prereq:
9
- // CREATE TABLE [User] (
9
+ // CREATE TABLE [Users] (
10
10
  // [UserID] VARCHAR(50) NOT NULL,
11
11
  // [Enabled] VARCHAR(50) NULL,
12
12
  // [Password] VARCHAR(50) NULL,
@@ -19,7 +19,7 @@
19
19
  // PRIMARY KEY ([UserID])
20
20
  // );
21
21
  //
22
- // CREATE TABLE [Group] (
22
+ // CREATE TABLE [Groups] (
23
23
  // [GroupID] VARCHAR(50) NOT NULL,
24
24
  // [Enabled] VARCHAR(50) NULL,
25
25
  // CONSTRAINT [PK_Group]
@@ -33,11 +33,11 @@
33
33
  // PRIMARY KEY ([GroupID],[UserID]),
34
34
  // CONSTRAINT [FK_U2G_Group]
35
35
  // FOREIGN KEY ([GroupID])
36
- // REFERENCES [Group]([GroupID])
36
+ // REFERENCES [Groups]([GroupID])
37
37
  // ON DELETE CASCADE,
38
38
  // CONSTRAINT [FK_U2G_Users]
39
39
  // FOREIGN KEY ([UserID])
40
- // REFERENCES [User]([UserID])
40
+ // REFERENCES [Users]([UserID])
41
41
  // ON DELETE CASCADE
42
42
  // );
43
43
  //
@@ -66,7 +66,7 @@ import { Connection, Request } from 'tedious'
66
66
  const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
67
67
  try {
68
68
  return (await import('scimgateway')).ScimGateway
69
- } catch (err) {
69
+ } catch (err: any) {
70
70
  const source = './scimgateway.ts'
71
71
  return (await import(source)).ScimGateway
72
72
  }
@@ -113,17 +113,13 @@ scimgateway.getUsers = async (baseEntity, getObj, attributes, ctx) => {
113
113
  if (!sqlQuery) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
114
114
 
115
115
  try {
116
- return await new Promise(async (resolve, reject) => {
116
+ return await new Promise(async (resolve) => {
117
117
  const ret: any = { // itemsPerPage will be set by scimgateway
118
118
  Resources: [],
119
119
  totalResults: null,
120
120
  }
121
121
 
122
- const users: any[] = await query(sqlQuery, ctx).catch((err: any) => {
123
- const e = new Error(`${action} error: ${err.message}`)
124
- return reject(e)
125
- })
126
-
122
+ const users: any[] = await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
127
123
  for (const user of users) {
128
124
  const scimUser = {
129
125
  id: user.UserID.value ? user.UserID.value : undefined,
@@ -155,7 +151,7 @@ scimgateway.createUser = async (baseEntity, userObj, ctx) => {
155
151
  scimgateway.logDebug(baseEntity, `handling "${action}" userObj=${JSON.stringify(userObj)}`)
156
152
 
157
153
  try {
158
- return await new Promise(async (resolve, reject) => {
154
+ return await new Promise(async (resolve) => {
159
155
  if (!userObj.name) userObj.name = {}
160
156
  if (!userObj.emails) userObj.emails = { work: {} }
161
157
  if (!userObj.phoneNumbers) userObj.phoneNumbers = { work: {} }
@@ -174,10 +170,7 @@ scimgateway.createUser = async (baseEntity, userObj, ctx) => {
174
170
  const sqlQuery = `insert into [Users] (UserID, Enabled, Password, FirstName, MiddleName, LastName, Email, MobilePhone)
175
171
  values (${insert.UserID}, ${insert.Enabled}, ${insert.Password}, ${insert.FirstName}, ${insert.MiddleName}, ${insert.LastName}, ${insert.Email}, ${insert.MobilePhone})`
176
172
 
177
- await query(sqlQuery, ctx).catch((err: any) => {
178
- const e = new Error(`${action} error: ${err.message}`)
179
- return reject(e)
180
- })
173
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
181
174
 
182
175
  resolve(null)
183
176
  }) // Promise
@@ -194,12 +187,9 @@ scimgateway.deleteUser = async (baseEntity, id, ctx) => {
194
187
  scimgateway.logDebug(baseEntity, `handling "${action}" id=${id}`)
195
188
 
196
189
  try {
197
- return await new Promise(async (resolve, reject) => {
190
+ return await new Promise(async (resolve) => {
198
191
  const sqlQuery = `delete from [Users] where UserID = '${id}'`
199
- await query(sqlQuery, ctx).catch((err: any) => {
200
- const e = new Error(`${action} error: ${err.message}`)
201
- return reject(e)
202
- })
192
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
203
193
 
204
194
  resolve(null)
205
195
  }) // Promise
@@ -216,7 +206,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
216
206
  scimgateway.logDebug(baseEntity, `handling "${action}" id=${id} attrObj=${JSON.stringify(attrObj)}`)
217
207
 
218
208
  try {
219
- return await new Promise(async (resolve, reject) => {
209
+ return await new Promise(async (resolve) => {
220
210
  if (!attrObj.name) attrObj.name = {}
221
211
  if (!attrObj.emails) attrObj.emails = { work: {} }
222
212
  if (!attrObj.phoneNumbers) attrObj.phoneNumbers = { work: {} }
@@ -252,10 +242,7 @@ scimgateway.modifyUser = async (baseEntity, id, attrObj, ctx) => {
252
242
  sql = sql.substr(0, sql.length - 1) // remove trailing ","
253
243
 
254
244
  const sqlQuery = `update [Users] set ${sql} where UserID like '${id}'`
255
- await query(sqlQuery, ctx).catch((err: any) => {
256
- const e = new Error(`${action} error: ${err.message}`)
257
- return reject(e)
258
- })
245
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
259
246
 
260
247
  resolve(null)
261
248
  }) // Promise
@@ -296,16 +283,13 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
296
283
  if (!sqlQuery) throw new Error(`${action} error: mandatory if-else logic not fully implemented`)
297
284
 
298
285
  try {
299
- return await new Promise(async (resolve, reject) => {
286
+ return await new Promise(async (resolve) => {
300
287
  const ret: any = { // itemsPerPage will be set by scimgateway
301
288
  Resources: [],
302
289
  totalResults: null,
303
290
  }
304
291
 
305
- const groups = await query(sqlQuery, ctx).catch((err: any) => {
306
- const e = new Error(`${action} error: ${err.message}`)
307
- return reject(e)
308
- })
292
+ const groups: any[] = await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
309
293
 
310
294
  for (const group of groups) {
311
295
  const scimGroup: Record<string, any> = {
@@ -316,7 +300,7 @@ scimgateway.getGroups = async (baseEntity, getObj, attributes, ctx) => {
316
300
  }
317
301
 
318
302
  const sqlQuery = `select UserID from [Users2Group] where GroupID = '${scimGroup.id}'`
319
- const members = await query(sqlQuery, ctx).catch(e => console.warn(`${e}`))
303
+ const members = await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
320
304
  for (const member of members) {
321
305
  const scimMember = {
322
306
  value: member.UserID.value,
@@ -343,21 +327,20 @@ scimgateway.createGroup = async (baseEntity, groupObj, ctx) => {
343
327
  scimgateway.logDebug(baseEntity, `handling "${action}" groupObj=${JSON.stringify(groupObj)}`)
344
328
 
345
329
  try {
346
- return await new Promise(async (resolve, reject) => {
330
+ return await new Promise(async (resolve) => {
347
331
  const insert = {
348
332
  GroupID: `'${groupObj.displayName}'`,
349
333
  Enabled: (groupObj.active) ? `'${groupObj.active}'` : '\'false\'',
350
334
  }
351
335
 
352
336
  const sqlQuery = `insert into [Groups] (GroupID, Enabled) values (${insert.GroupID}, ${insert.Enabled})`
353
- await query(sqlQuery, ctx).catch(e => console.warn(`${e}`))
354
-
355
- for (const member of groupObj.members) {
356
- const sqlQuery = `insert into [Users2Group] (UserID, GroupID) values ('${member.value}', ${insert.GroupID})`
357
- await query(sqlQuery, ctx).catch((err: any) => {
358
- const e = new Error(`${action} error: ${err.message}`)
359
- return reject(e)
360
- })
337
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
338
+
339
+ if (Array.isArray(groupObj.members) && groupObj.members) {
340
+ for (const member of groupObj.members) {
341
+ const sqlQuery = `insert into [Users2Group] (UserID, GroupID) values ('${member.value}', ${insert.GroupID})`
342
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
343
+ }
361
344
  }
362
345
 
363
346
  resolve(null)
@@ -375,12 +358,9 @@ scimgateway.deleteGroup = async (baseEntity, id, ctx) => {
375
358
  scimgateway.logDebug(baseEntity, `handling "${action}" id=${id}`)
376
359
 
377
360
  try {
378
- return await new Promise(async (resolve, reject) => {
361
+ return await new Promise(async (resolve) => {
379
362
  const sqlQuery = `delete from [Groups] where GroupID = '${id}'`
380
- await query(sqlQuery, ctx).catch((err: any) => {
381
- const e = new Error(`${action} error: ${err.message}`)
382
- return reject(e)
383
- })
363
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
384
364
 
385
365
  resolve(null)
386
366
  }) // Promise
@@ -409,25 +389,22 @@ scimgateway.modifyGroup = async (baseEntity, id, attrObj, ctx) => {
409
389
  // This BLINDLY inserts all user/groups and gracefully breaks on PK violation
410
390
  // for each existing membership
411
391
  if (Array.isArray(attrObj.members) && attrObj.members) {
412
- attrObj.members.forEach((member) => {
392
+ for (const member of attrObj.members) {
413
393
  if (member.operation == 'delete') {
414
394
  queries.push(`delete from [Users2Group] where GroupID='${id}' and UserID='${member.value}'`)
415
395
  } else {
416
396
  queries.push(`insert into [Users2Group] (UserID, GroupID) values ('${member.value}','${id}')`)
417
397
  }
418
- })
398
+ }
419
399
  }
420
400
 
421
401
  const sqlQuery = queries.join(';')
422
402
 
423
403
  try {
424
- return await new Promise(async (resolve, reject) => {
404
+ return await new Promise(async (resolve) => {
425
405
  if (sqlQuery) {
426
406
  scimgateway.logDebug(baseEntity, `sqlQuery: ${sqlQuery}`)
427
- await query(sqlQuery, ctx).catch((err: any) => {
428
- const e = new Error(`${action} error: ${err.message}`)
429
- return reject(e)
430
- })
407
+ await query(sqlQuery, ctx).catch(err => scimgateway.logWarn(baseEntity, `${action} warning: ${err.message}`))
431
408
  }
432
409
 
433
410
  resolve(null)
@@ -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
@@ -2367,13 +2367,25 @@ export class ScimGateway {
2367
2367
  if (!ctx.response.body) ctx.response.body = 'NOT_FOUND'
2368
2368
  break
2369
2369
  }
2370
- const response = new Response(ctx.response.body, { status: ctx.response.status, headers: ctx.response.headers })
2371
- if (ctx.response.body) {
2370
+ const body = ctx.response.body
2371
+ if (body) {
2372
2372
  try {
2373
- JSON.parse(ctx.response.body)
2374
- response.headers.set('content-type', 'application/scim+json; charset=utf-8')
2373
+ JSON.parse(body)
2374
+ ctx.response.headers.set('content-type', 'application/scim+json; charset=utf-8')
2375
2375
  } catch (err) { void 0 }
2376
2376
  }
2377
+ let response: Response
2378
+ if (typeof Bun !== 'undefined') {
2379
+ const stream = new ReadableStream({ // ensure Bun compatibility with Azure Reverse Proxy for large and long running response - header set by Bun: Transfer-Encoding: 'chunked'
2380
+ start(controller) {
2381
+ controller.enqueue(body)
2382
+ controller.close()
2383
+ },
2384
+ })
2385
+ response = new Response(body ? stream : body, { status: ctx.response.status, headers: ctx.response.headers })
2386
+ } else {
2387
+ response = new Response(body, { status: ctx.response.status, headers: ctx.response.headers })
2388
+ }
2377
2389
  logResult(ctx)
2378
2390
  return response
2379
2391
  }
@@ -2698,6 +2710,13 @@ export class ScimGateway {
2698
2710
  this.logger.info(`${this.pluginName}[${baseEntity}] ${msg}`)
2699
2711
  }
2700
2712
 
2713
+ /**
2714
+ * logWarn logs warning message
2715
+ **/
2716
+ logWarn(baseEntity: string | undefined, msg: string) {
2717
+ this.logger.warn(`${this.pluginName}[${baseEntity}] ${msg}`)
2718
+ }
2719
+
2701
2720
  /**
2702
2721
  * logError logs error message
2703
2722
  **/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.7",
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": {