scimgateway 5.1.4 → 5.1.6

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
@@ -36,7 +36,7 @@ Latest news:
36
36
 
37
37
  ## Overview
38
38
 
39
- With SCIM Gateway, user management is facilitated through the utilization of the REST-based SCIM 1.1 or 2.0 protocol. The gateway acts as a translator for incoming SCIM requests, seamlessly enabling the exposure of CRUD functionality (create, read, update, and delete user/group) towards destinations. This is achieved through the implementation of endpoint-specific protocols, ensuring precise and efficient provisioning with diverse endpoints.
39
+ SCIM Gateway facilitates user management using the standardized REST-based SCIM 1.1 or 2.0 protocol, offering easier, more powerful, and consistent provisioning while avoiding vendor lock-in. Acting as a translator for incoming SCIM requests, the gateway seamlessly enables CRUD functionality (create, read, update, and delete) for users and groups. By implementing endpoint-specific protocols, it ensures precise and efficient provisioning across diverse destinations. With the gateway, your diverse destinations effectively become SCIM endpoints, streamlining integration and simplifying user management.
40
40
 
41
41
 
42
42
  ![](https://jelhub.github.io/images/ScimGateway.svg)
@@ -150,7 +150,7 @@ If internet connection is blocked, we could install on another machine and copy
150
150
 
151
151
  >Tip, take a look at bun test scripts located in `node_modules\scimgateway\test\lib`
152
152
 
153
- > If using Node.js instead of Bun, scimgateway must be downloaded from github and startup:
153
+ > If using Node.js instead of Bun, scimgateway must be downloaded from github because Node.js does not support native typescript used by modules. Startup will then be:
154
154
  node --experimental-strip-types c:\my-scimgateway\index.ts
155
155
 
156
156
  #### Upgrade SCIM Gateway
@@ -465,7 +465,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
465
465
 
466
466
  - **endpoint** - Contains endpoint specific configuration according to customized **plugin code**.
467
467
 
468
- #### Configuration notes
468
+ ### Configuration notes - general
469
469
 
470
470
  - Custom Schemas, ServiceProviderConfig and ResourceType can be used if `./lib/scimdef-v2.json or scimdef-v1.json` exists. Original scimdef-v2.json/scimdef-v1.json can be copied from node_modules/scimgateway/lib to your plugin/lib and customized.
471
471
  - Using reverse proxy and we want ipAllowList and correct meta.location response, following headers must be set by proxy: `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host`
@@ -521,88 +521,204 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
521
521
  "plugin-soap.endpoint.password": "secret"
522
522
  }
523
523
 
524
+ ### Configuration notes - Email, using Microsoft Exchange Online (ExO)
524
525
 
525
- - Email, using Microsoft Exchange Online (ExO)
526
-
527
- - Entra ID application must have application permissions `Mail.Send`
528
- - To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
529
-
530
- First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
531
- Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
532
-
533
- ##Connect to Exchange
534
- Install-Module -Name ExchangeOnlineManagement
535
- Connect-ExchangeOnline
536
-
537
- ##Create ApplicationAccessPolicy
538
- New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
539
-
540
- - Email, using Google Workspace Gmail
541
-
542
- - https://console.cloud.google.com
543
- - IAM & Admin > Service Accounts > Create Service Account
544
- - Name=email-sender
545
- - Create and Continue
546
- - Grant this service account access to project - not needed
547
- - Grant users access to this service - not needed
548
- - IAM & Admin > Service Accounts > "email-sender" account > Keys
549
- - Add Key > Create new key > JSON
550
- - download json Service Account Key file, refere to configuration `email.auth.options.serviceAccountKeyFile`
551
-
552
- - https://admin.google.com
553
- - Security > Access and data control > API controls
554
- - Manage Domain Wide Delegation > Add new
555
- - Client ID = id of service account created
556
- - OAuth scope = `https://www.googleapis.com/auth/gmail.send`
557
-
558
- - https://admin.google.com
559
- - Billing > Subscriptions - verify Google Workspace license
560
- - Directory > Users > "user"
561
- - Licenses > Edit > enable Google Workspace license
562
- `email.emailOnError.from` mail address must have Google Workspace license
563
-
564
- - Gateway chainging and chainingBaseUrl configuration
565
-
566
- By configuring the `chainingBaseUrl`, it is possible to chain multiple gateways in sequence, such as `gateway1->gateway2->gateway3->endpoint`. In this setup, gateway behave much like a reverse proxy, validating authorization at each step unless PassThrough mode is enabled. Chaining is also supported in stream subscriber mode
526
+ - Entra ID application must have application permissions `Mail.Send`
527
+ - To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
567
528
 
568
- {
569
- "scimgateway": {
570
- ...
571
- "chainingBaseUrl": "https:\\gateway2:8880",
572
- ...
573
- "auth": {
574
- ...
575
- "passThrough": {
576
- "enabled": false,
577
- "readOnly": false,
578
- "baseEntities": []
579
- }
580
- ...
581
- }
582
- },
529
+ First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
530
+ Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
531
+
532
+ ##Connect to Exchange
533
+ Install-Module -Name ExchangeOnlineManagement
534
+ Connect-ExchangeOnline
535
+
536
+ ##Create ApplicationAccessPolicy
537
+ New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
538
+
539
+ ### Configuration notes - Email, using Google Workspace Gmail
540
+
541
+ - https://console.cloud.google.com
542
+ - IAM & Admin > Service Accounts > Create Service Account
543
+ - Name=email-sender
544
+ - Create and Continue
545
+ - Grant this service account access to project - not needed
546
+ - Grant users access to this service - not needed
547
+ - IAM & Admin > Service Accounts > "email-sender" account > Keys
548
+ - Add Key > Create new key > JSON
549
+ - download json Service Account Key file, refere to configuration `email.auth.options.serviceAccountKeyFile`
550
+
551
+ - https://admin.google.com
552
+ - Security > Access and data control > API controls
553
+ - Manage Domain Wide Delegation > Add new
554
+ - Client ID = id of service account created
555
+ - OAuth scope = `https://www.googleapis.com/auth/gmail.send`
556
+
557
+ - https://admin.google.com
558
+ - Billing > Subscriptions - verify Google Workspace license
559
+ - Directory > Users > "user"
560
+ - Licenses > Edit > enable Google Workspace license
561
+ `email.emailOnError.from` mail address must have Google Workspace license
562
+
563
+ ### Configuration notes - Gateway chainging and chainingBaseUrl
564
+
565
+ By configuring the `chainingBaseUrl`, it is possible to chain multiple gateways in sequence, such as `gateway1->gateway2->gateway3->endpoint`. In this setup, gateway behave much like a reverse proxy, validating authorization at each step unless PassThrough mode is enabled. Chaining is also supported in stream subscriber mode
566
+
567
+ {
568
+ "scimgateway": {
569
+ ...
570
+ "chainingBaseUrl": "https:\\gateway2:8880",
571
+ ...
572
+ "auth": {
573
+ ...
574
+ "passThrough": {
575
+ "enabled": false,
576
+ "readOnly": false,
577
+ "baseEntities": []
578
+ }
583
579
  ...
584
- }
585
-
586
-
587
- Using above configuration example on gateway1, incoming requests will be routed to `https:\\gateway2:8880`
580
+ }
581
+ },
582
+ ...
583
+ }
584
+
585
+
586
+ Using above configuration example on gateway1, incoming requests will be routed to `https:\\gateway2:8880`
587
+
588
+ The plugin and its associated authentication configuration can mirror the setup running on the final gateway. However, in chaining mode, the plugin binary is used solely for initializing and configuring the gateway. This allows for the use of a simplified `plugin-<name>.ts` binary containing only the essential mandatory components:
588
589
 
589
- The plugin and its associated authentication configuration can mirror the setup running on the final gateway. However, in chaining mode, the plugin binary is used solely for initializing and configuring the gateway. This allows for the use of a simplified `plugin-<name>.ts` binary containing only the essential mandatory components:
590
-
591
- // start - mandatory plugin initialization
592
- const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
593
- try {
594
- return (await import('scimgateway')).ScimGateway
595
- } catch (err) {
596
- const source = './scimgateway.ts'
597
- return (await import(source)).ScimGateway
598
- }
599
- })()
600
- const scimgateway = new ScimGateway()
601
- const config = scimgateway.getConfig()
602
- scimgateway.authPassThroughAllowed = false
603
- // end - mandatory plugin initialization
590
+ // start - mandatory plugin initialization
591
+ const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
592
+ try {
593
+ return (await import('scimgateway')).ScimGateway
594
+ } catch (err) {
595
+ const source = './scimgateway.ts'
596
+ return (await import(source)).ScimGateway
597
+ }
598
+ })()
599
+ const scimgateway = new ScimGateway()
600
+ const config = scimgateway.getConfig()
601
+ scimgateway.authPassThroughAllowed = false
602
+ // end - mandatory plugin initialization
603
+
604
+ Using `scimgateway.authPassThroughAllowed = true` and `plugin-<name>.json` configuration `scimgateway.auth.passThrough=true` enables Authentication PassTrhough
605
+
606
+ ### Configuration notes - HelperRest used by plugins
607
+ For REST endpoints, plugins may use HelperRest to simplify authentication and communication
608
+ doRequest() executes REST request and return response
609
+ `doRequest(<baseEntity>, <method>, <path>, <body>, <ctx>, <options>)`
610
+
611
+ * baseEntity - 'undefined' if not used and must correspond with endpoint configuration that defines baseUrls and connection options.
612
+ * method - GET, PATCH, PUT, DELETE
613
+ * path - either full url or just the path that will be added to baseUrl. Using full url will override baseUrl. Using path is preferred because of auth caching logic and simplicity
614
+ * body - optional body to be used
615
+ * ctx - optional, passing authorization header if Auth PassThrough is enabled
616
+ * opt - optional, connection options that will extend/override any endpoint.entity.undefined.connection definitions
617
+
618
+ Configuration showing connection settings:
619
+
620
+ {
621
+ "scimgateway": {
622
+ ...
623
+ }
624
+ "endpoint": {
625
+ "entity": {
626
+ "undefined": {
627
+ "connection": {
628
+ "baseUrls": [],
629
+ "auth": {
630
+ "type": "xxx",
631
+ "options": {
632
+ ...
633
+ "jwtPayload": {},
634
+ "samlPayload": {},
635
+ "tls": {} // files located in ./config/certs
636
+ }
637
+ },
638
+ "options": {
639
+ "headers": {},
640
+ "tls": {} // files located in ./config/certs
641
+ },
642
+ "proxy": {}
643
+ }
644
+ }
645
+ }
646
+ }
647
+ }
648
+
649
+
650
+ * baseUrls - Endpoint URL. Several may be defined for failower. There are retry logic on connection failures
651
+ * auth.type - defines authentication being used: `basic`, `oauth`, `token`, `bearer`, `oauthSamlBearer` or `oauthJwtBearer`
652
+ * auth.options - for each valid type there are different options. tenantIdGUID is special for Entra ID and serviceAccountKeyFile is special for Google. Using these will simplify and reduce options to be included. Also note we do not need to include baseUrls when using tenantIdGUID/serviceAccountKeyFile as long as endpoint is Entra ID (Microsoft Graph) or Google.
653
+
654
+ Example using basic auth:
655
+
656
+ "connection": {
657
+ "baseUrls": [
658
+ "https://localhost:8880"
659
+ ],
660
+ "auth": {
661
+ "type": "basic",
662
+ "options": {
663
+ "username": "gwadmin",
664
+ "password": "password"
665
+ }
666
+ },
667
+ "options": {
668
+ "tls": {
669
+ "rejectUnauthorized": false,
670
+ "ca": "ca.pem"
671
+ }
672
+ }
673
+ }
674
+
675
+ Example Entra ID (plugin-entra-id) using clientId/clientSecret:
676
+
677
+ "connection": {
678
+ "baseUrls": [],
679
+ "auth": {
680
+ "type": "oauth",
681
+ "options": {
682
+ "tenantIdGUID": "<tenantId>",
683
+ "clientId": "<clientId",
684
+ "clientSecret": "<clientSecret>"
685
+ }
686
+ }
687
+ }
688
+
689
+ Example Entra ID (plugin-entra-id) using certificate secret:
690
+
691
+ "connection": {
692
+ "baseUrls": [],
693
+ "auth": {
694
+ "type": "oauth",
695
+ "options": {
696
+ "tenantIdGUID": "<tenantId>",
697
+ "clientId": "<clientId",
698
+ "tls": {
699
+ "key": "key.pem",
700
+ "cert": "cert.pem"
701
+ }
702
+ }
703
+ }
704
+ }
705
+
706
+ Example using general OAuth:
707
+
708
+ "connection": {
709
+ "baseUrls": ["endpointUrl"],
710
+ "auth": {
711
+ "type": "oauth",
712
+ "options": {
713
+ "tokenUrl": "<tokenUrl>"
714
+ "clientId": "<clientId",
715
+ "clientSecret": "<clientSecret>"
716
+ }
717
+ }
718
+ }
719
+
720
+ Please see code editor method HelperRest doRequest() IntelliSense for type and option details
604
721
 
605
- Using `scimgateway.authPassThroughAllowed = true` and `plugin-<name>.json` configuration `scimgateway.auth.passThrough=true` enables Authentication PassTrhough
606
722
 
607
723
  ## Manual startup
608
724
 
@@ -1147,7 +1263,7 @@ If using REST, we could also include the HelperRest:
1147
1263
  ...
1148
1264
  // end - mandatory plugin initialization
1149
1265
 
1150
- Plugins should include following SCIM methods:
1266
+ Plugins should include following SCIM Gateway methods:
1151
1267
 
1152
1268
  * scimgateway.getUsers()
1153
1269
  * scimgateway.createUser()
@@ -1175,6 +1291,19 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1175
1291
 
1176
1292
  ## Change log
1177
1293
 
1294
+ ### v5.1.6
1295
+
1296
+ [Improved]
1297
+
1298
+ - HelperRest, payload/claims configuration now defined in auth.options.jwtPayload and auth.options.samlPayload. Previously all was defiend in auth.options
1299
+ - README configuration notes updated
1300
+
1301
+ ### v5.1.5
1302
+
1303
+ [Improved]
1304
+
1305
+ - 404 NOT_FOUND is now logged as a warning instead of error
1306
+
1178
1307
  ### v5.1.4
1179
1308
 
1180
1309
  [Fixed]
@@ -1199,7 +1328,7 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1199
1328
  "options": {
1200
1329
  "tenantIdGUID": "Entra ID Tenant ID (GUID)",
1201
1330
  "clientId": "<application clientId>",
1202
- "certificate": { // files located in ./config/certs
1331
+ "tls": { // files located in ./config/certs
1203
1332
  "key": "key.pem",
1204
1333
  "cert": "cert.pem"
1205
1334
  }
@@ -42,7 +42,7 @@ export class HelperRest {
42
42
  if (this.config_entity[baseEntity]?.connection) {
43
43
  connectionFound = true
44
44
  const type = this.config_entity[baseEntity].connection?.auth?.type
45
- if (type === 'oauthJwtBearer' || type === 'oauth') { // includes oauth because of email.auth.type
45
+ if (type === 'oauthJwtBearer' || type === 'oauth') {
46
46
  // set default baseUrls for Entra ID and Google if not already defined
47
47
  if (this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID) { // Entra ID, setting baseUrls to graph
48
48
  if (!this.config_entity[baseEntity].connection.baseUrls) {
@@ -83,29 +83,32 @@ export class HelperRest {
83
83
 
84
84
  const action = 'getAccessToken'
85
85
 
86
+ const serviceAccountKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
87
+ const tenantIdGUID = this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID
86
88
  let tokenUrl: string
87
- let form: object
89
+ let form: Record<string, any>
88
90
  let resource = ''
89
91
 
92
+ try {
93
+ const urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
94
+ resource = urlObj.origin
95
+ } catch (err) { void 0 }
96
+ if (tenantIdGUID) {
97
+ tokenUrl = `https://login.microsoftonline.com/${tenantIdGUID}/oauth2/v2.0/token`
98
+ if (resource) this.config_entity[baseEntity].connection.auth.options.scope = resource + '/.default' // "https://graph.microsoft.com/.default"
99
+ } else tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
100
+
90
101
  try {
91
102
  switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
92
103
  case 'oauth':
93
- try {
94
- const urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
95
- resource = urlObj.origin
96
- } catch (err) { void 0 }
97
- if (this.config_entity[baseEntity].connection.auth?.options?.tenantIdGUID) { // Azure
98
- tokenUrl = `https://login.microsoftonline.com/${this.config_entity[baseEntity].connection.auth.options.tenantIdGUID}/oauth2/token`
99
- } else {
100
- tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
101
- }
102
104
  form = {
103
105
  grant_type: 'client_credentials',
104
106
  client_id: this.config_entity[baseEntity].connection.auth.options.clientId,
105
107
  client_secret: this.config_entity[baseEntity].connection.auth.options.clientSecret,
106
- scope: this.config_entity[baseEntity].connection.auth.options.scope || null,
107
- resource: resource || null, // "https://graph.microsoft.com"
108
108
  }
109
+ if (this.config_entity[baseEntity].connection.auth.options.scope) form.scope = this.config_entity[baseEntity].connection.auth.options.scope // required using Entra ID /oauth2/v2.0/token
110
+ if (this.config_entity[baseEntity].connection.auth.options.resource) resource = this.config_entity[baseEntity].connection.auth.options.resource // required using Entra ID /oauth2/token
111
+
109
112
  break
110
113
 
111
114
  case 'token':
@@ -119,23 +122,26 @@ export class HelperRest {
119
122
  case 'oauthSamlBearer':
120
123
  tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
121
124
  const context = null
122
- const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.cert).toString()
123
- const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.key).toString()
124
-
125
- const issuer = `scimgateway/${this.scimgateway.pluginName}`
126
- const lifetime = 3600
127
- const clientId = this.config_entity[baseEntity].connection.auth.options.clientId
128
- const nameId = this.config_entity[baseEntity].connection.auth.options.userId
129
- const userIdentifierFormat = 'userName'
125
+ const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert).toString()
126
+ const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key).toString()
127
+
130
128
  const tokenEndpoint = tokenUrl
131
- const audience = `scimgateway/${this.scimgateway.pluginName}`
132
129
  const delay = 1
133
130
 
131
+ // mandatory: clientId, companyId and nameId
132
+ const clientId = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId
133
+ const companyId = this.config_entity[baseEntity].connection.auth.options.samlPayload.companyId
134
+ const nameId = this.config_entity[baseEntity].connection.auth.options.samlPayload.nameId
135
+ const userIdentifierFormat = this.config_entity[baseEntity].connection.auth.options.samlPayload.userIdentifierFormat || 'userName'
136
+ const lifetime = this.config_entity[baseEntity].connection.auth.options.samlPayload.lifetime || 3600
137
+ const issuer = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId || `https://scimgateway.${this.scimgateway.pluginName}.com`
138
+ const audience = this.config_entity[baseEntity].connection.auth.options.samlPayload.audience || `scimgateway/${this.scimgateway.pluginName}`
139
+
134
140
  form = {
135
141
  token_url: tokenUrl,
136
142
  grant_type: 'urn:ietf:params:oauth:grant-type:saml2-bearer',
137
143
  client_id: clientId,
138
- company_id: this.config_entity[baseEntity].connection.auth.options.companyId,
144
+ company_id: companyId,
139
145
  assertion: await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay),
140
146
  }
141
147
  break
@@ -143,25 +149,21 @@ export class HelperRest {
143
149
  case 'oauthJwtBearer':
144
150
  let jwtClaims: jsonwebtoken.JwtPayload | Record<string, any> = {}
145
151
  let jwtOpts: jsonwebtoken.SignOptions = {}
146
- const serviceAccountKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
147
- const tenantIdGUID = this.config_entity[baseEntity]?.connection?.auth?.options?.tenantIdGUID
148
152
 
149
- if (tenantIdGUID) {
150
- // Microsoft Entra ID
151
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key || !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.cert) {
152
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.certificate.key/cert configuration`)
153
+ if (tenantIdGUID) { // Microsoft Entra ID
154
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
155
+ throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
153
156
  }
154
- tokenUrl = `https://login.microsoftonline.com/${tenantIdGUID}/oauth2/v2.0/token` // `https://login.microsoftonline.com/${tenantIdGUID}/oauth2/token`
155
- let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?._key || ''
156
- let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?._cert || ''
157
+ let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
158
+ let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
157
159
  if (!privateKey || !cert) {
158
- privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.key, 'utf-8') || ''
159
- cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.cert, 'utf-8') || ''
160
- if (privateKey) this.config_entity[baseEntity].connection.auth.options.certificate._key = privateKey
161
- if (cert) this.config_entity[baseEntity].connection.auth.options.certificate._cert = cert
160
+ privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
161
+ cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
162
+ if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
163
+ if (cert) this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
162
164
  }
163
165
  if (!privateKey || !cert) {
164
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.certificate.key/cert file content`)
166
+ throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
165
167
  }
166
168
 
167
169
  const jwtPayload: jsonwebtoken.JwtPayload = {
@@ -199,23 +201,16 @@ export class HelperRest {
199
201
  }
200
202
  */
201
203
 
202
- let scope = 'https://graph.microsoft.com/.default'
203
- try {
204
- const urlObj = new URL(this.config_entity[baseEntity].connection.baseUrls[0])
205
- scope = urlObj.origin + '/.default' // for application exposed api's and included permissions use: api://${this.config_entity[baseEntity]?.connection?.auth?.options?.clientId}/.default
206
- } catch (err) { void 0 }
207
-
208
204
  form = {
209
- scope,
210
205
  grant_type: 'client_credentials',
206
+ scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
211
207
  client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
212
208
  client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
213
209
  client_assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
214
210
  }
215
- } else if (serviceAccountKeyFile) {
216
- // Google - using Service Account key json-file
217
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.subject) {
218
- const err = new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.scope/subject`)
211
+ } else if (serviceAccountKeyFile) { // Google - using Service Account key json-file
212
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject) {
213
+ const err = new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.jwtPayload.scope/subject`)
219
214
  throw err
220
215
  }
221
216
  let gkey: Record<string, any> = this.config_entity[baseEntity]?.connection?.auth?.options?._gkey
@@ -234,7 +229,7 @@ export class HelperRest {
234
229
  tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
235
230
  const privateKey = gkey.private_key
236
231
  const jwtPayload: jsonwebtoken.JwtPayload = {
237
- sub: this.config_entity[baseEntity]?.connection?.auth?.options?.subject, // firstname.lastname@mycompany.com
232
+ sub: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
238
233
  iss: gkey.client_email, // service account email/user
239
234
  aud: gkey.token_uri,
240
235
  iat: Math.floor(Date.now() / 1000) - 60, // issued at
@@ -242,7 +237,7 @@ export class HelperRest {
242
237
  }
243
238
  jwtClaims = {
244
239
  ...jwtPayload,
245
- scope: this.config_entity[baseEntity]?.connection?.auth?.options?.scope, // https://www.googleapis.com/auth/gmail.send
240
+ scope: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
246
241
  }
247
242
  jwtOpts = {
248
243
  algorithm: 'RS256',
@@ -257,23 +252,23 @@ export class HelperRest {
257
252
  assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
258
253
  }
259
254
  } else {
260
- // standard JWT - requires all configuation: tokenUrl, rawJwtPayload and certificate.key
255
+ // standard JWT - requires all configuation: tokenUrl, jwtPayload and tls.key
261
256
  if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
262
- || !this.config_entity[baseEntity]?.connection?.auth?.options?.rawJwtPayload
263
- || typeof this.config_entity[baseEntity]?.connection?.auth?.options?.rawJwtPayload !== 'object') {
264
- throw new Error(`auth.type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/rawJwtPayload`)
257
+ || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload
258
+ || typeof this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload !== 'object') {
259
+ throw new Error(`auth.type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/jwtPayload`)
265
260
  }
266
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key) {
267
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing options.certificate.key configuration`)
261
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
262
+ throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no tenantIdGUID/serviceAccountKeyFile using raw) - missing options.tls.key configuration`)
268
263
  }
269
264
  tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
270
- let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?._key || ''
265
+ let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
271
266
  if (!privateKey) {
272
- privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.key, 'utf-8') || ''
273
- if (privateKey) this.config_entity[baseEntity].connection.auth.options.certificate._key = privateKey
267
+ privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
268
+ if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
274
269
  }
275
270
 
276
- let jwtPayload = this.config_entity[baseEntity].connection.auth.options.rawJwtPayload
271
+ let jwtPayload = this.config_entity[baseEntity].connection.auth.options.jwtPayload
277
272
  if (!jwtPayload.iat) jwtPayload.iat = Math.floor(Date.now() / 1000) - 60
278
273
  if (!jwtPayload.exp) jwtPayload.exp = Math.floor(Date.now() / 1000) + 3600
279
274
 
@@ -419,6 +414,18 @@ export class HelperRest {
419
414
  org = utils.extendObj(org, opt.connection)
420
415
  }
421
416
 
417
+ // may use configuration type='oauth' and auto corrected to 'oauthJwtBearer'
418
+ if (this.config_entity[baseEntity]?.connection?.auth?.type == 'oauth') {
419
+ if (this.config_entity[baseEntity].connection.auth?.options?.tenantIdGUID) {
420
+ if (this.config_entity[baseEntity].connection.auth.options?.tls?.cert
421
+ && this.config_entity[baseEntity].connection.auth.options?.tls?.key
422
+ && this.config_entity[baseEntity].connection.auth.options.clientId
423
+ ) this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
424
+ } else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) {
425
+ this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
426
+ }
427
+ }
428
+
422
429
  switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
423
430
  case 'basic':
424
431
  if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
@@ -451,9 +458,9 @@ export class HelperRest {
451
458
  param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
452
459
  break
453
460
  case 'oauthSamlBearer':
454
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.companyId
455
- || !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key) {
456
- const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options...`)
461
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.companyId
462
+ || !this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
463
+ const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options.tls and/or options.samlPayload.clientId/companyId`)
457
464
  throw err
458
465
  }
459
466
  param.accessToken = await this.getAccessToken(baseEntity, ctx)
@@ -462,7 +469,7 @@ export class HelperRest {
462
469
  case 'oauthJwtBearer':
463
470
  // auth.options.tenantIdGUID => Microsoft Entra ID
464
471
  // auth.options.serviceAccountKeyFile => Google Service Account
465
- // also support custom using tokenUrl/rawJwtPayload
472
+ // also support custom using tokenUrl/jwtPayload
466
473
  param.accessToken = await this.getAccessToken(baseEntity, ctx)
467
474
  param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
468
475
  break
@@ -764,7 +771,7 @@ export class HelperRest {
764
771
  * ```
765
772
  * type defines authentication being used
766
773
  * if type not defined, no authentication used
767
- * valid type is: `basic`, `oauth`, `token`, `bearer` or `oauthSamlBearer`
774
+ * valid type is: `basic`, `oauth`, `token`, `bearer`, `oauthSamlBearer` or `oauthJwtBearer`
768
775
  *
769
776
  * for each valid type there are different auth.options
770
777
  *
@@ -815,10 +822,16 @@ export class HelperRest {
815
822
  * {
816
823
  * "options": {
817
824
  * "tokenUrl": "<tokenUrl>",
818
- * "clientId": "<clientId>",
819
- * "companyId": "<companyId>",
820
- * "userId": "<userId>",
821
- * "certificate": {
825
+ * "samlPayload": {
826
+ * "clientId": "<clientId>",
827
+ * "companyId": "<companyId>",
828
+ * "nameId": "<nameId>",
829
+ * "lifetime": "<optional>"
830
+ * "issuer": "<optional>",
831
+ * "userIdentifierFormat": "<optional>",
832
+ * "audience": "<optional>"
833
+ * },
834
+ * "tls": {
822
835
  * "key": "<key-file-name>", // location: config/certs
823
836
  * "cert": "<cert-file-name>", // location: config/certs
824
837
  * }
@@ -833,7 +846,7 @@ export class HelperRest {
833
846
  * "options": {
834
847
  * "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
835
848
  * "clientId": "<clientId>",
836
- * "certificate": { // files located in ./config/certs
849
+ * "tls": { // files located in ./config/certs
837
850
  * "key": "key.pem",
838
851
  * "cert": "cert.pem"
839
852
  * }
@@ -853,10 +866,10 @@ export class HelperRest {
853
866
  * {
854
867
  * "options": {
855
868
  * "tokenUrl": "<tokenUrl",
856
- * "certificate": {
869
+ * "tls": {
857
870
  * "key": "<signing-key-file-name>" // key.pem file located in ./config/certs
858
871
  * },
859
- * "rawJwtPayload": {
872
+ * "jwtPayload": {
860
873
  * "sub": "<subject>",
861
874
  * "iss": "<issuer>",
862
875
  * "aud": "<audience>",
@@ -305,7 +305,6 @@ export class ScimGateway {
305
305
 
306
306
  constructor() {
307
307
  const funcHandler: any = {}
308
- const startTime = utils.timestamp()
309
308
  let requester: string = ''
310
309
  {
311
310
  let _prepareStackTrace = Error.prepareStackTrace
@@ -374,7 +373,6 @@ export class ScimGateway {
374
373
 
375
374
  const oAuthTokenExpire = 3600 // seconds
376
375
  let pwErrCount = 0
377
- let requestCounter = 0
378
376
  let isMailLock = false
379
377
  let ipAllowListChecker: any
380
378
  let server: any
@@ -554,9 +552,9 @@ export class ScimGateway {
554
552
  if (authType === 'Basic') [userName] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
555
553
  if (!userName && authType === 'Bearer') userName = 'token'
556
554
  if (ctx.response.status && (ctx.response.status < 200 || ctx.response.status > 299)) {
557
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${ctx.response.body}${(this.config.scimgateway.log.loglevel.file === 'debug' && ctx.request.url !== '/ping') ? '\n' : ''}`)
555
+ if (ctx.response.status === 404) logger.warn(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${ctx.response.body}${(this.config.scimgateway.log.loglevel.file === 'debug' && ctx.request.url !== '/ping') ? '\n' : ''}`)
556
+ else logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${ctx.response.body}${(this.config.scimgateway.log.loglevel.file === 'debug' && ctx.request.url !== '/ping') ? '\n' : ''}`)
558
557
  } else logger.info(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${ctx.response.status} ${userName} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${ctx.response.body}${(this.config.scimgateway.log.loglevel.file === 'debug' && ctx.request.url !== '/ping') ? '\n' : ''}`)
559
- requestCounter += 1 // logged on exit (not win process termination)
560
558
  }
561
559
 
562
560
  // start auth methods - used by auth
@@ -2540,12 +2538,12 @@ export class ScimGateway {
2540
2538
  isMailLock = false
2541
2539
  }, (this.config.scimgateway.email.emailOnError.sendInterval || 15) * 1000 * 60)
2542
2540
 
2543
- const msgHtml = `<html><body><pre style="font-family: monospace; white-space: pre-wrap;">${msg}</pre><br/><p><strong>This is an automatically generated email - please do NOT reply to this email or forward to others</strong></p></body></html>`
2541
+ const msgHtml = `<html><body><pre style="font-family: monospace; white-space: pre-wrap;">${msg}</pre><br/><p><strong>This is an automatically generated email - please do NOT reply to this email</strong></p></body></html>`
2544
2542
  const msgObj = {
2545
2543
  from: this.config.scimgateway.email.emailOnError.from,
2546
2544
  to: this.config.scimgateway.email.emailOnError.to,
2547
2545
  cc: this.config.scimgateway.email.emailOnError.cc,
2548
- subject: this.config.scimgateway.email.emailOnError.subject ? this.config.scimgateway.email.emailOnError.subject : 'SCIM Gateway error message',
2546
+ subject: this.config.scimgateway.email.emailOnError.subject || 'SCIM Gateway error message',
2549
2547
  content: msgHtml,
2550
2548
  }
2551
2549
  this.sendMail(msgObj, true)
@@ -2558,9 +2556,6 @@ export class ScimGateway {
2558
2556
  }
2559
2557
  }
2560
2558
  logger.debug(`${gwName}[${pluginName}] received terminate/kill signal - closing connections and exit`)
2561
- logger.setLoglevelConsole('info')
2562
- logger.setLoglevelFile('info')
2563
- logger.info(`${gwName}[${pluginName}] pheww... ${requestCounter} requests have been processed in the period ${startTime} - ${utils.timestamp()}\n`)
2564
2559
  logger.close()
2565
2560
  if (server) {
2566
2561
  if (typeof Bun !== 'undefined') {
@@ -2875,9 +2870,7 @@ export class ScimGateway {
2875
2870
 
2876
2871
  const path = `/users/${msgObj.from}/sendMail`
2877
2872
  try {
2878
- if (this.config.scimgateway.email.auth?.options?.certificate?.key && this.config.scimgateway.email.auth?.options?.certificate?.cert) {
2879
- await this.helperRest.doRequest('undefined', 'POST', path, emailMessage, null, { connection: { auth: { type: 'oauthJwtBearer' } } })
2880
- } else await this.helperRest.doRequest('undefined', 'POST', path, emailMessage)
2873
+ await this.helperRest.doRequest('undefined', 'POST', path, emailMessage)
2881
2874
  logger.debug(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
2882
2875
  } catch (err: any) {
2883
2876
  logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: ${err.message}`)
@@ -2899,7 +2892,7 @@ Content-Transfer-Encoding: quoted-printable
2899
2892
  const emailMessage = { raw: encodedMessage }
2900
2893
  const path = `/gmail/v1/users/${msgObj.from}/messages/send`
2901
2894
  try { // using opt connection argument type=oauthJwtBearer and options scope/subject because we want to keep simplified email.auth.type=oauth and options serviceAccountKeyFile
2902
- await this.helperRest.doRequest('undefined', 'POST', path, emailMessage, null, { connection: { auth: { type: 'oauthJwtBearer', options: { scope: 'https://www.googleapis.com/auth/gmail.send', subject: msgObj.from } } } })
2895
+ await this.helperRest.doRequest('undefined', 'POST', path, emailMessage, null, { connection: { auth: { type: 'oauthJwtBearer', options: { jwtPayload: { scope: 'https://www.googleapis.com/auth/gmail.send', subject: msgObj.from } } } } })
2903
2896
  logger.debug(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
2904
2897
  } catch (err: any) {
2905
2898
  logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: ${err.message}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.1.4",
3
+ "version": "5.1.6",
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)",