scimgateway 5.4.3 → 5.5.0

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
@@ -16,6 +16,8 @@ Validated through IdP's:
16
16
 
17
17
  Latest news:
18
18
 
19
+ - Entra ID [Federated Identity Credentials](https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0) is now supported. Identity federation allows SCIM Gateway to access Microsoft Entra protected resources without needing to manage secrets
20
+ - External JWKS (JSON Web Key Set) is now supported by JWT Authentication. These are public and typically frequent rotated by modern identity providers
19
21
  - [Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) is now supported for secure and hassle-free outbound communication — with just one minute of configuration
20
22
  - [ETag](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14) is now supported
21
23
  - [Bulk Operations](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) is now supported
@@ -417,7 +419,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
417
419
 
418
420
  - **auth.bearerJwtAzure** - Array of one or more JWT used by Azure SyncFabric. **tenantIdGUID** must be set to Entra ID Tenant ID.
419
421
 
420
- - **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.
422
+ - **auth.bearerJwt** - Array of one or more standard JWT objects. Using **secret**, **publicKey** or **wellKnownUri** 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. For JWKS (JSON Web Key Set), the **wellKnownUri** must be set to identity provider well-known URI which will be used for lookup the jwks_uri key. **options.issuer** should normally be set for validation when using secret or publicKey, for JWKS the issuer will be included automatically. Other options may also be included according to the JWT standard.
421
423
 
422
424
  - **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`
423
425
 
@@ -715,7 +717,7 @@ Example Entra ID (plugin-entra-id) using certificate secret:
715
717
  "connection": {
716
718
  "baseUrls": [],
717
719
  "auth": {
718
- "type": "oauth",
720
+ "type": "oauthJwtBearer",
719
721
  "options": {
720
722
  "tenantIdGUID": "<tenantId>",
721
723
  "clientId": "<clientId>",
@@ -727,6 +729,27 @@ Example Entra ID (plugin-entra-id) using certificate secret:
727
729
  }
728
730
  }
729
731
 
732
+ Example Entra ID (plugin-entra-id) using federated credentials:
733
+
734
+ "connection": {
735
+ "baseUrls": [],
736
+ "auth": {
737
+ "type": "oauthJwtBearer",
738
+ "options": {
739
+ "tenantIdGUID": "<tenantId>",
740
+ "fedCred": {
741
+ "issuer": "<https://FQDN-scimgateway/oauth>",
742
+ "subject": "<entra id application object id - client id>",
743
+ "name": "<entra id federated credentials unique name>"
744
+ }
745
+ }
746
+ }
747
+ }
748
+ // Note: Federated credentials defined for the application in Entra ID must match the corresponding `issuer`, `subject`, and `name`
749
+ // example issuer: "https://scimgateway.my-company.com/oauth" and must be reachable from the internet
750
+ // exampole name: "plugin-entra-id"
751
+
752
+
730
753
  Example using general OAuth:
731
754
 
732
755
  "connection": {
@@ -816,7 +839,7 @@ const messageHandler = async (message: string) => {
816
839
  console.log(message)
817
840
  }
818
841
 
819
- async function startSSE() {
842
+ async function startup() {
820
843
  while (true) {
821
844
  try {
822
845
  const resp = await fetch(url, { headers });
@@ -847,7 +870,7 @@ async function startSSE() {
847
870
  }
848
871
  }
849
872
 
850
- startSSE()
873
+ startup()
851
874
  ```
852
875
 
853
876
  ### Configuration notes - Azure Relay
@@ -1466,7 +1489,68 @@ In code editor (e.g., Visual Studio Code), method details and documentation are
1466
1489
  MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1467
1490
 
1468
1491
 
1469
- ## Change log
1492
+ ## Change log
1493
+
1494
+ ### v5.5.0
1495
+
1496
+ [Improved]
1497
+
1498
+ - Entra ID [Federated Identity Credentials](https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0) is now supported. Identity federation allows SCIM Gateway to access Microsoft Entra protected resources without needing to manage secrets
1499
+
1500
+ helper-rest includes options for federated credentials:
1501
+
1502
+ "auth {
1503
+ "type": "oauthJwtBearer",
1504
+ "options": {
1505
+ "tenantIdGUID": "<Entra ID tenantIdGUID",
1506
+ "fedCred": {
1507
+ "issuer": "<https://FQDN-scimgateway/oauth>",
1508
+ "subject": "<entra id application object id - client id>",
1509
+ "name": "<entra id federated credentials unique name>"
1510
+ }
1511
+ }
1512
+ }
1513
+
1514
+ Example:
1515
+
1516
+ "auth {
1517
+ "type": "oauthJwtBearer",
1518
+ "options": {
1519
+ "tenantIdGUID": "11111111-2222-3333-4444-555555555555",
1520
+ "fedCred": {
1521
+ "issuer": "https://scimgateway.my-company.com/oauth>",
1522
+ "subject": "99999999-8888-7777-6666-555555555555",
1523
+ "name": "plugin-entra-id"
1524
+ }
1525
+ }
1526
+ }
1527
+
1528
+ Note: Federated credentials defined for the application in Entra ID must match the corresponding `issuer`, `subject`, and `name` values defined in the SCIM Gateway endpoint configuration. An example of this can be using `plugin-entra-id` and other plugins that interact with endpoints or applications protected by Entra ID.
1529
+
1530
+ Also note: SCIM Gateway must be reachable from the internet (as defined by the `issuer` URL). This requires allowing inbound internet communication — or alternatively, Azure Relay can be used for outbound-only communication.
1531
+
1532
+
1533
+ ### v5.4.4
1534
+
1535
+ [Improved]
1536
+
1537
+ - External JWKS (JSON Web Key Set) is now supported by JWT Authentication. These are public and typically frequent rotated by modern identity providers
1538
+
1539
+ JKWS is enabled by setting scimgateway.auth.bearerJwt[].wellKnownUri to the identity provider's well-known URI
1540
+
1541
+ Keycloak example:
1542
+
1543
+ auth: {
1544
+ "bearerJwt": [
1545
+ {
1546
+ "wellKnownUri": "https://keycloak.example.com/realms/example-realm/.well-known/openid-configuration",
1547
+ "options": {
1548
+ ...
1549
+ },
1550
+ ...
1551
+ }
1552
+ ]
1553
+ }
1470
1554
 
1471
1555
  ### v5.4.3
1472
1556
 
package/bun.lock CHANGED
@@ -16,7 +16,7 @@
16
16
  "https-proxy-agent": "^7.0.6",
17
17
  "hyco-https": "^1.4.5",
18
18
  "is-in-subnet": "^4.0.1",
19
- "jsonwebtoken": "^9.0.2",
19
+ "jose": "^6.0.11",
20
20
  "ldapjs": "^3.0.7",
21
21
  "lokijs": "^1.5.12",
22
22
  "mongodb": "^6.16.0",
@@ -27,7 +27,7 @@
27
27
  "saml": "^3.0.1",
28
28
  },
29
29
  "devDependencies": {
30
- "@stylistic/eslint-plugin": "^4.4.0",
30
+ "@stylistic/eslint-plugin": "^5.1.0",
31
31
  "@types/bun": "latest",
32
32
  "@types/dot-object": "^2.1.6",
33
33
  "@types/jsonwebtoken": "^9.0.9",
@@ -141,7 +141,7 @@
141
141
 
142
142
  "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
143
143
 
144
- "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="],
144
+ "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/types": "^8.34.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-TJRJul4u/lmry5N/kyCU+7RWWOk0wyXN+BncRlDYBqpLFnzXkd7QGVfN7KewarFIXv0IX0jSF/Ksu7aHWEDeuw=="],
145
145
 
146
146
  "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
147
147
 
@@ -449,6 +449,8 @@
449
449
 
450
450
  "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
451
451
 
452
+ "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
453
+
452
454
  "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
453
455
 
454
456
  "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -50,6 +50,7 @@
50
50
  {
51
51
  "secret": null,
52
52
  "publicKey": null,
53
+ "wellKnownUri": null,
53
54
  "options": {
54
55
  "issuer": null
55
56
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -10,10 +10,11 @@
10
10
  import { HttpsProxyAgent } from 'https-proxy-agent'
11
11
  import { URL } from 'url'
12
12
  import { Buffer } from 'node:buffer'
13
+ import { createPublicKey, createPrivateKey, createHash } from 'node:crypto'
13
14
  import { samlAssertion } from './samlAssertion.ts'
14
- import * as jsonwebtoken from 'jsonwebtoken'
15
15
  import fs from 'node:fs'
16
16
  import querystring from 'querystring'
17
+ import * as jose from 'jose'
17
18
  import * as utils from './utils.ts'
18
19
 
19
20
  /**
@@ -148,66 +149,118 @@ export class HelperRest {
148
149
  break
149
150
 
150
151
  case 'oauthJwtBearer':
151
- let jwtClaims: jsonwebtoken.JwtPayload | Record<string, any> = {}
152
- let jwtOpts: jsonwebtoken.SignOptions = {}
152
+ let jwtClaims: jose.JWTPayload | Record<string, any>
153
+ let jwtHeaders: jose.JWTHeaderParameters
153
154
 
154
155
  if (tenantIdGUID) { // Microsoft Entra ID
155
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
156
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
157
- }
158
- let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
159
- let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
160
- if (!privateKey || !cert) {
161
- privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
162
- cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
163
- if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
164
- if (cert) this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
165
- }
166
- if (!privateKey || !cert) {
167
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
168
- }
169
-
170
- const jwtPayload: jsonwebtoken.JwtPayload = {
171
- sub: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
172
- iss: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
173
- aud: `https://login.microsoftonline.com/${tenantIdGUID}/v2.0`,
174
- iat: Math.floor(Date.now() / 1000) - 60,
175
- exp: Math.floor(Date.now() / 1000) + 3600,
176
- jti: crypto.randomUUID(),
177
- nbf: Math.floor(Date.now() / 1000) - 60,
178
- }
179
- jwtClaims = {
180
- ...jwtPayload,
181
- }
182
-
183
- const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha1') // xt5=>sha1, x5t#S256=>sha256
184
- jwtOpts = {
185
- algorithm: 'RS256',
186
- header: {
187
- typ: 'JWT',
156
+ if (this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer) { // federated credentials
157
+ const name = JSON.stringify(this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.name) // ensure not using none valid json key
158
+ if (!this.scimgateway.jwk) this.scimgateway.jwk = {}
159
+ if (!this.scimgateway.jwk[baseEntity]) this.scimgateway.jwk[baseEntity] = {}
160
+ if (!this.scimgateway.jwk[baseEntity][name]) {
161
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
162
+ this.scimgateway.jwk[baseEntity][name] = { publicKey, privateKey }
163
+ const ttl = 5 * 60 // 5 minutes
164
+ ;(async () => {
165
+ // rotate - delete JWK after 5 minutes, will be regenerated on next token request
166
+ // entra id only lookup well-known uri and corresponding jwks_uri on token request validation if kid not found in entra cached JWKS
167
+ setTimeout(async () => {
168
+ delete this.scimgateway.jwk[baseEntity][name]
169
+ }, ttl * 1000)
170
+ })()
171
+ }
172
+
173
+ this.scimgateway.jwk[baseEntity].issuer = this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer // updates .well-known
174
+
175
+ const now = Date.now()
176
+ const jwtPayload: jose.JWTPayload = {
177
+ iss: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer, // entra id federated credentials issuer e.g. https://scimgateway.my-company.com/oauth
178
+ sub: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.subject, // entra id application object id - client id
179
+ name: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.name, // entra id federated credentials unique name e.g. plugin-entra-id
180
+ aud: 'api://AzureADTokenExchange', // entra id federated credentials audience
181
+ // below is not used by entra id federated credentials token-generation - could be skipped
182
+ iat: Math.floor(now / 1000) - 60,
183
+ exp: Math.floor(now / 1000) + 3600,
184
+ jti: crypto.randomUUID(),
185
+ nbf: Math.floor(now / 1000) - 60,
186
+ }
187
+ jwtClaims = {
188
+ ...jwtPayload,
189
+ }
190
+
191
+ const jwk = await jose.exportJWK(this.scimgateway.jwk[baseEntity][name].publicKey)
192
+ const kid = createHash('sha256') // kid required for JWKS
193
+ .update(JSON.stringify(jwk))
194
+ .digest('base64url')
195
+
196
+ jwtHeaders = {
188
197
  alg: 'RS256',
189
- x5t: base64Thumbprint,
190
- },
191
- }
192
-
193
- /* Microsoft recommended modern x5t#S256 does not work using self-signed certificate
194
- const base64Thumbprint = utils.getBase64CertificateThumbprint(cert, 'sha256')
195
- jwtOpts = {
196
- algorithm: 'PS256',
197
- header: {
198
+ typ: 'JWT',
199
+ kid,
200
+ }
201
+
202
+ form = {
203
+ grant_type: 'client_credentials',
204
+ scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
205
+ client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.subject,
206
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
207
+ client_assertion: await new jose.SignJWT(jwtClaims)
208
+ .setProtectedHeader(jwtHeaders)
209
+ .sign(this.scimgateway.jwk[baseEntity][name].privateKey),
210
+ }
211
+ } else { // standard certificate
212
+ if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
213
+ throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
214
+ }
215
+ let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
216
+ let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
217
+ let certPem = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._certPem || ''
218
+ if (!privateKey || !cert) {
219
+ const privateKeyPem = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
220
+ certPem = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
221
+ if (privateKeyPem) {
222
+ privateKey = createPrivateKey(privateKeyPem) // PEM => KeyObject
223
+ this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
224
+ }
225
+ if (certPem) {
226
+ cert = createPublicKey(certPem)
227
+ this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
228
+ this.config_entity[baseEntity].connection.auth.options.tls._certPem = certPem
229
+ }
230
+ }
231
+ if (!privateKey || !cert) {
232
+ throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
233
+ }
234
+
235
+ const jwtPayload: jose.JWTPayload = {
236
+ iss: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
237
+ sub: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
238
+ aud: `https://login.microsoftonline.com/${tenantIdGUID}/v2.0`,
239
+ iat: Math.floor(Date.now() / 1000) - 60,
240
+ exp: Math.floor(Date.now() / 1000) + 3600,
241
+ jti: crypto.randomUUID(),
242
+ nbf: Math.floor(Date.now() / 1000) - 60,
243
+ }
244
+ jwtClaims = {
245
+ ...jwtPayload,
246
+ }
247
+
248
+ const base64Thumbprint = utils.getBase64CertificateThumbprint(certPem, 'sha256') // x5t=>sha1, x5t#S256=>sha256
249
+ jwtHeaders = {
250
+ 'alg': 'RS256',
198
251
  'typ': 'JWT',
199
- 'alg': 'PS256',
200
- 'x5t#S256': base64Thumbprint,
201
- },
202
- }
203
- */
204
-
205
- form = {
206
- grant_type: 'client_credentials',
207
- scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
208
- client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
209
- client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
210
- client_assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
252
+ 'x5t#S256': base64Thumbprint, // Microsoft recommend modern x5t#S256 over x5t
253
+ }
254
+
255
+ form = {
256
+ grant_type: 'client_credentials',
257
+ scope: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
258
+ client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
259
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
260
+ client_assertion: await new jose.SignJWT(jwtClaims)
261
+ .setProtectedHeader(jwtHeaders)
262
+ .sign(privateKey),
263
+ }
211
264
  }
212
265
  } else if (serviceAccountKeyFile) { // Google - using Service Account key json-file
213
266
  if (!this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject) {
@@ -228,10 +281,10 @@ export class HelperRest {
228
281
  }
229
282
 
230
283
  tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
231
- const privateKey = gkey.private_key
232
- const jwtPayload: jsonwebtoken.JwtPayload = {
233
- sub: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
284
+ const privateKey = createPrivateKey(gkey.private_key) // PEM => KeyObject
285
+ const jwtPayload: jose.JWTPayload = {
234
286
  iss: gkey.client_email, // service account email/user
287
+ sub: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
235
288
  aud: gkey.token_uri,
236
289
  iat: Math.floor(Date.now() / 1000) - 60, // issued at
237
290
  exp: Math.floor(Date.now() / 1000) + 3600, // expiration time
@@ -240,17 +293,17 @@ export class HelperRest {
240
293
  ...jwtPayload,
241
294
  scope: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
242
295
  }
243
- jwtOpts = {
244
- algorithm: 'RS256',
245
- header: {
246
- typ: 'JWT',
247
- alg: 'RS256',
248
- kid: gkey.client_id,
249
- },
296
+ jwtHeaders = {
297
+ alg: 'RS256',
298
+ typ: 'JWT',
299
+ kid: gkey.client_id,
250
300
  }
301
+
251
302
  form = {
252
303
  grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
253
- assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
304
+ assertion: await new jose.SignJWT(jwtClaims)
305
+ .setProtectedHeader(jwtHeaders)
306
+ .sign(privateKey),
254
307
  }
255
308
  } else {
256
309
  // standard JWT - requires all configuation: tokenUrl, jwtPayload and tls.key
@@ -266,27 +319,29 @@ export class HelperRest {
266
319
  let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
267
320
  if (!privateKey) {
268
321
  privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
269
- if (privateKey) this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
322
+ if (privateKey) {
323
+ privateKey = createPrivateKey(privateKey)
324
+ this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
325
+ }
270
326
  }
271
327
 
272
- let jwtPayload = this.config_entity[baseEntity].connection.auth.options.jwtPayload
328
+ let jwtPayload: jose.JWTPayload = this.config_entity[baseEntity].connection.auth.options.jwtPayload
273
329
  if (!jwtPayload.iat) jwtPayload.iat = Math.floor(Date.now() / 1000) - 60
274
330
  if (!jwtPayload.exp) jwtPayload.exp = Math.floor(Date.now() / 1000) + 3600
275
331
 
276
332
  jwtClaims = {
277
333
  ...jwtPayload,
278
334
  }
279
- jwtOpts = {
280
- algorithm: 'RS256',
281
- header: {
282
- typ: 'JWT',
283
- alg: 'RS256',
284
- },
335
+ jwtHeaders = {
336
+ alg: 'RS256',
337
+ typ: 'JWT',
285
338
  }
286
339
 
287
340
  form = {
288
341
  grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
289
- assertion: jsonwebtoken.sign(jwtClaims, privateKey, jwtOpts),
342
+ assertion: await new jose.SignJWT(jwtClaims)
343
+ .setProtectedHeader(jwtHeaders)
344
+ .sign(privateKey),
290
345
  }
291
346
  }
292
347
 
@@ -410,8 +465,8 @@ export class HelperRest {
410
465
  if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
411
466
  let org = this.config_entity[baseEntity]?.connection
412
467
  orgConnection = utils.copyObj(org)
413
- if (!orgConnection) orgConnection = {}
414
- orgConnection = utils.extendObj(orgConnection, opt.connection)
468
+ if (!org) org = {}
469
+ org = utils.extendObj(org, opt.connection)
415
470
  }
416
471
 
417
472
  // may use configuration type='oauth' and auto corrected to 'oauthJwtBearer'
@@ -699,6 +754,7 @@ export class HelperRest {
699
754
  try { urlObj = new URL(path) } catch (err) { void 0 }
700
755
  let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
701
756
  let oAuthTokeErr = statusCode === 401 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
757
+
702
758
  if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || statusCode === 504 || oAuthTokeErr || retryAfter)) {
703
759
  this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
704
760
  if (retryAfter) {
@@ -709,8 +765,10 @@ export class HelperRest {
709
765
  }
710
766
  if (retryCount < this.config_entity[baseEntity].connection.baseUrls.length) {
711
767
  retryCount++
712
- this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
713
- this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
768
+ if (isServiceClient) {
769
+ this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
770
+ this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
771
+ }
714
772
  if (oAuthTokeErr) {
715
773
  delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
716
774
  }
@@ -778,6 +836,7 @@ export class HelperRest {
778
836
  * type=**"basic"** having auth.options:
779
837
  * ```
780
838
  * {
839
+ * "type": "basic",
781
840
  * "options": {
782
841
  * "username": "<username>",
783
842
  * "password": "<password>"
@@ -788,6 +847,7 @@ export class HelperRest {
788
847
  * type=**"oauth"** having auth.options:
789
848
  * ```
790
849
  * {
850
+ * "type": "oauth",
791
851
  * "options": {
792
852
  * "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication - if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
793
853
  * "tokenUrl": "<tokenUrl>", // must be set if not using tenantIdGUID
@@ -800,6 +860,7 @@ export class HelperRest {
800
860
  * type=**"token"** having auth.options:
801
861
  * ```
802
862
  * {
863
+ * "type": "token",
803
864
  * "options": {
804
865
  * "tokenUrl": "<url for requesting token">
805
866
  * "username": "<user name for token request>"
@@ -811,6 +872,7 @@ export class HelperRest {
811
872
  * type=**"bearer"** having auth.options:
812
873
  * ```
813
874
  * {
875
+ * "type": "bearer",
814
876
  * "options": {
815
877
  * "token": "<bearer token to be used">
816
878
  * }
@@ -820,6 +882,7 @@ export class HelperRest {
820
882
  * type=**"oauthSamlBearer"** having auth.options:
821
883
  * ```
822
884
  * {
885
+ * "type": "oauthSamlBearer",
823
886
  * "options": {
824
887
  * "tokenUrl": "<tokenUrl>",
825
888
  * "samlPayload": {
@@ -841,8 +904,9 @@ export class HelperRest {
841
904
  *
842
905
  * type=**"oauthJwtBearer"** having auth.options:
843
906
  * ```
844
- * // Microsoft Entra ID
907
+ * // Microsoft Entra ID - using certificate
845
908
  * {
909
+ * "type": "oauthJwtBearer",
846
910
  * "options": {
847
911
  * "tenantIdGUID": "<Entra ID tenantIdGUID", // Entra ID authentication, if baseUrls not defined, baseUrls automatically set to [https://graph.microsoft.com/beta]
848
912
  * "clientId": "<clientId>",
@@ -853,8 +917,23 @@ export class HelperRest {
853
917
  * }
854
918
  * }
855
919
  *
920
+ * // Microsoft Entra ID - using Federated credentials
921
+ * // Note, fedCred configuration must match corresponding configuration in Entra ID Application - Federation credentials
922
+ * {
923
+ * "type": "oauthJwtBearer",
924
+ * "options": {
925
+ * "tenantIdGUID": "<Entra ID tenantIdGUID",
926
+ * "fedCred": {
927
+ * "issuer": "<https://FQDN-scimgateway/oauth>", // e.g. https://scimgateway.my-company.com/oauth
928
+ * "subject": "<entra id application object id - client id>",
929
+ * "name": "<entra id federated credentials unique name>" // e.g. plugin-entra-id
930
+ * }
931
+ * }
932
+ * }
933
+ *
856
934
  * // Google Cloud Platform - GCP
857
935
  * {
936
+ * "type": "oauthJwtBearer",
858
937
  * "options": {
859
938
  * "serviceAccountKeyFile": "<Google Service Account key file name>", // located in ./config/certs. If baseUrls not defined, baseUrls automatically set to [https://www.googleapis.com]
860
939
  * "scope": "<jwt-scope>",
@@ -864,6 +943,7 @@ export class HelperRest {
864
943
  *
865
944
  * // General JWT API
866
945
  * {
946
+ * "type": "oauthJwtBearer",
867
947
  * "options": {
868
948
  * "tokenUrl": "<tokenUrl",
869
949
  * "tls": {