scimgateway 5.4.4 → 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,7 @@ 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
19
20
  - External JWKS (JSON Web Key Set) is now supported by JWT Authentication. These are public and typically frequent rotated by modern identity providers
20
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
21
22
  - [ETag](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14) is now supported
@@ -418,7 +419,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
418
419
 
419
420
  - **auth.bearerJwtAzure** - Array of one or more JWT used by Azure SyncFabric. **tenantIdGUID** must be set to Entra ID Tenant ID.
420
421
 
421
- - **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. 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.
422
423
 
423
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`
424
425
 
@@ -716,7 +717,7 @@ Example Entra ID (plugin-entra-id) using certificate secret:
716
717
  "connection": {
717
718
  "baseUrls": [],
718
719
  "auth": {
719
- "type": "oauth",
720
+ "type": "oauthJwtBearer",
720
721
  "options": {
721
722
  "tenantIdGUID": "<tenantId>",
722
723
  "clientId": "<clientId>",
@@ -728,6 +729,27 @@ Example Entra ID (plugin-entra-id) using certificate secret:
728
729
  }
729
730
  }
730
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
+
731
753
  Example using general OAuth:
732
754
 
733
755
  "connection": {
@@ -1469,6 +1491,45 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1469
1491
 
1470
1492
  ## Change log
1471
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
+
1472
1533
  ### v5.4.4
1473
1534
 
1474
1535
  [Improved]
package/bun.lock CHANGED
@@ -16,8 +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",
20
- "jwk-to-pem": "^2.0.7",
19
+ "jose": "^6.0.11",
21
20
  "ldapjs": "^3.0.7",
22
21
  "lokijs": "^1.5.12",
23
22
  "mongodb": "^6.16.0",
@@ -28,11 +27,10 @@
28
27
  "saml": "^3.0.1",
29
28
  },
30
29
  "devDependencies": {
31
- "@stylistic/eslint-plugin": "^4.4.0",
30
+ "@stylistic/eslint-plugin": "^5.1.0",
32
31
  "@types/bun": "latest",
33
32
  "@types/dot-object": "^2.1.6",
34
33
  "@types/jsonwebtoken": "^9.0.9",
35
- "@types/jwk-to-pem": "^2.0.3",
36
34
  "@types/node": "latest",
37
35
  "@types/nodemailer": "^6.4.17",
38
36
  "@types/passport": "^1.0.17",
@@ -143,7 +141,7 @@
143
141
 
144
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=="],
145
143
 
146
- "@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=="],
147
145
 
148
146
  "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
149
147
 
@@ -165,8 +163,6 @@
165
163
 
166
164
  "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
167
165
 
168
- "@types/jwk-to-pem": ["@types/jwk-to-pem@2.0.3", "", {}, "sha512-I/WFyFgk5GrNbkpmt14auGO3yFK1Wt4jXzkLuI+fDBNtO5ZI2rbymyGd6bKzfSBEuyRdM64ZUwxU1+eDcPSOEQ=="],
169
-
170
166
  "@types/ldapjs": ["@types/ldapjs@3.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q=="],
171
167
 
172
168
  "@types/lokijs": ["@types/lokijs@1.5.14", "", {}, "sha512-4Fic47BX3Qxr8pd12KT6/T1XWU8dOlJBIp1jGoMbaDbiEvdv50rAii+B3z1b/J2pvMywcVP+DBPGP5/lgLOKGA=="],
@@ -239,8 +235,6 @@
239
235
 
240
236
  "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
241
237
 
242
- "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
243
-
244
238
  "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
245
239
 
246
240
  "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -259,14 +253,10 @@
259
253
 
260
254
  "bl": ["bl@6.1.0", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw=="],
261
255
 
262
- "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
263
-
264
256
  "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
265
257
 
266
258
  "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
267
259
 
268
- "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
269
-
270
260
  "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
271
261
 
272
262
  "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -325,8 +315,6 @@
325
315
 
326
316
  "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
327
317
 
328
- "elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="],
329
-
330
318
  "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
331
319
 
332
320
  "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -411,12 +399,8 @@
411
399
 
412
400
  "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
413
401
 
414
- "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
415
-
416
402
  "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
417
403
 
418
- "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
419
-
420
404
  "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
421
405
 
422
406
  "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="],
@@ -465,6 +449,8 @@
465
449
 
466
450
  "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
467
451
 
452
+ "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
453
+
468
454
  "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
469
455
 
470
456
  "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -479,8 +465,6 @@
479
465
 
480
466
  "jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
481
467
 
482
- "jwk-to-pem": ["jwk-to-pem@2.0.7", "", { "dependencies": { "asn1.js": "^5.3.0", "elliptic": "^6.6.1", "safe-buffer": "^5.0.1" } }, "sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ=="],
483
-
484
468
  "jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
485
469
 
486
470
  "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -525,10 +509,6 @@
525
509
 
526
510
  "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
527
511
 
528
- "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
529
-
530
- "minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="],
531
-
532
512
  "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
533
513
 
534
514
  "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -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": {
package/lib/logger.ts CHANGED
@@ -131,7 +131,7 @@ export class Logger {
131
131
 
132
132
  let customMask = this.customMasking || []
133
133
  if (!Array.isArray(customMask)) customMask = []
134
- const jsonMaskKeys = ['password', 'access_token', 'client_secret', 'assertion', 'client_assertion']
134
+ const jsonMaskKeys = ['password', 'access_token', 'client_secret', 'assertion', 'client_assertion', 'refresh_token']
135
135
  const jsonJoinedKeys = jsonMaskKeys.concat(customMask).join('|')
136
136
  const xmlMaskKeys = ['credentials', 'PasswordText', 'PasswordDigest', 'password']
137
137
  const xmlJoinedKeys = xmlMaskKeys.concat(customMask).join('"?|') + '"?'
@@ -11,6 +11,7 @@
11
11
  import { createServer as httpCreateServer } from 'node:http'
12
12
  import { createServer as httpsCreateServer } from 'node:https'
13
13
  import { type IncomingMessage, type ServerResponse } from 'node:http'
14
+ import { createPublicKey, createHash } from 'node:crypto'
14
15
  import { createChecker } from 'is-in-subnet'
15
16
  import { BearerStrategy, type IBearerStrategyOptionWithRequest } from 'passport-azure-ad'
16
17
  import { fileURLToPath } from 'node:url'
@@ -21,8 +22,7 @@ import dot from 'dot-object'
21
22
  import nodemailer from 'nodemailer'
22
23
  import fs from 'node:fs'
23
24
  import path from 'node:path'
24
- import jwkToPem from 'jwk-to-pem'
25
- import * as jwt from 'jsonwebtoken'
25
+ import * as jose from 'jose'
26
26
  import * as utils from './utils.ts'
27
27
  import * as utilsScim from './utils-scim.ts'
28
28
  import * as stream from './scim-stream.js'
@@ -33,6 +33,7 @@ export class ScimGateway {
33
33
  private logger: any
34
34
  private gwName: string
35
35
  private scimDef: any
36
+ private jwk: any
36
37
  private countries: any
37
38
  private multiValueTypes: any
38
39
  private getMemberOf: any
@@ -485,7 +486,7 @@ export class ScimGateway {
485
486
  getMethod: 'getAppRoles',
486
487
  }
487
488
  /** handlers supported url paths */
488
- const handlers = ['users', 'groups', 'bulk', 'serviceplans', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'logger']
489
+ const handlers = ['users', 'groups', 'bulk', 'serviceplans', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', 'logger']
489
490
 
490
491
  try {
491
492
  if (!fs.existsSync(configDir + '/wsdls')) fs.mkdirSync(configDir + '/wsdls')
@@ -585,10 +586,12 @@ export class ScimGateway {
585
586
  }
586
587
 
587
588
  if (ctx.response.status && (ctx.response.status < 200 || ctx.response.status > 299)) {
588
- if (ctx.response.status === 412 || ctx.response.status === 304) {
589
- logger.info(`${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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
589
+ if (ctx.response.status === 401 && !ctx.request.headers.get('authorization')) {
590
+ 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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
590
591
  } else if (ctx.response.status === 404) {
591
592
  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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
593
+ } else if (ctx.response.status === 412 || ctx.response.status === 304) {
594
+ logger.info(`${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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
592
595
  } 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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
593
596
  } 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=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
594
597
  }
@@ -596,19 +599,16 @@ export class ScimGateway {
596
599
  // start auth methods - used by auth
597
600
  const basic = async (baseEntity: string, method: string, authType: string, authToken: string, path: string): Promise<boolean> => {
598
601
  return await new Promise((resolve, reject) => { // basic auth
599
- if (authType !== 'Basic') resolve(false)
600
- if (!found.Basic) resolve(false)
601
- if (found.PassThrough && this.authPassThroughAllowed && !path.endsWith('/logger')) resolve(false) // Auth PassThrough browser logon dialog support
602
+ if (!found.Basic) return resolve(false)
603
+ if (authType !== 'Basic' || !authToken) return resolve(false)
604
+ if (found.PassThrough && this.authPassThroughAllowed && !path.endsWith('/logger')) return resolve(false) // Auth PassThrough browser logon dialog support
602
605
  const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
603
- if (!userName || !userPassword) {
604
- return reject(new Error(`authentication failed for user ${userName}`))
605
- }
606
+ if (!userName || !userPassword) return resolve(false)
606
607
  const arr = this.config.scimgateway.auth.basic
607
608
  for (let i = 0; i < arr.length; i++) {
608
609
  if (arr[i].username === userName && arr[i].password === userPassword) { // authentication OK
609
610
  if (arr[i].baseEntities) {
610
611
  if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
611
- if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
612
612
  if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].username} according to basic configuration baseEntitites=${arr[i].baseEntities}`))
613
613
  }
614
614
  }
@@ -616,20 +616,19 @@ export class ScimGateway {
616
616
  return resolve(true)
617
617
  }
618
618
  }
619
- reject(new Error(`authentication failed for user ${userName}`))
619
+ resolve(false)
620
620
  })
621
621
  }
622
622
 
623
623
  const bearerToken = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
624
624
  return await new Promise((resolve, reject) => { // bearer token
625
- if (authType !== 'Bearer' || !authToken) resolve(false)
626
- if (!found.BearerToken) resolve(false)
625
+ if (!found.BearerToken) return resolve(false)
626
+ if (authType !== 'Bearer' || !authToken) return resolve(false)
627
627
  const arr = this.config.scimgateway.auth.bearerToken
628
628
  for (let i = 0; i < arr.length; i++) {
629
629
  if (arr[i].token === authToken) { // authentication OK
630
630
  if (arr[i].baseEntities) {
631
631
  if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
632
- if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
633
632
  if (!arr[i].baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerToken according to bearerToken configuration baseEntitites=${arr[i].baseEntities}`))
634
633
  }
635
634
  }
@@ -637,24 +636,29 @@ export class ScimGateway {
637
636
  return resolve(true)
638
637
  }
639
638
  }
640
- reject(new Error('bearerToken authentication failed'))
639
+ resolve(false)
641
640
  })
642
641
  }
643
642
 
644
643
  const bearerJwtAzure = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
645
644
  return await new Promise((resolve, reject) => {
646
- if (authType !== 'Bearer' || !found.BearerJwtAzure) resolve(false) // no azure bearer token
647
- const jtoken: any = jwt.decode(authToken, { complete: true })
648
- if (jtoken == null) resolve(false)
649
- else if (!jtoken.payload['iss']) resolve(false)
650
- if (jtoken?.payload['iss'].indexOf('https://sts.windows.net') !== 0) resolve(false)
645
+ if (!found.BearerJwtAzure) return resolve(false)
646
+ if (authType !== 'Bearer' || !authToken) return resolve(false)
647
+ let payload
648
+ try {
649
+ payload = jose.decodeJwt(authToken)
650
+ if (!payload || !payload.iss) return resolve(false)
651
+ if (!payload.iss.startsWith('https://sts.windows.net')) return resolve(false)
652
+ } catch (err: any) {
653
+ return resolve(false)
654
+ }
651
655
  const req = { headers: { authorization: `${authType} ${authToken}` } } // Node.js http.createServer type IncomingMessage - header supported by passport
652
656
  passport.authenticate('oauth-bearer', { session: false }, (err: any, user: any, info: any) => {
653
657
  if (err) { return reject(err) }
654
658
  if (user) { // authenticated OK
655
659
  const arr = this.config.scimgateway.auth.bearerJwtAzure
656
660
  for (let i = 0; i < arr.length; i++) {
657
- if (arr[i].tenantIdGUID && jtoken?.payload['iss'].includes(arr[i].tenantIdGUID)) {
661
+ if (arr[i].tenantIdGUID && payload.iss.includes(arr[i].tenantIdGUID)) {
658
662
  if (arr[i].baseEntities) {
659
663
  if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
660
664
  if (!baseEntity) return reject(new Error(`baseEntity=${baseEntity} not allowed for user ${arr[i].tenantIdGUID} according to bearerJwtAzure configuration baseEntitites=${arr[i].baseEntities}`))
@@ -670,89 +674,99 @@ export class ScimGateway {
670
674
  })
671
675
  }
672
676
 
673
- const getJwksPemKey = async (kid: string, wellKnownUri: string): Promise<Array<any>> => { // retrieves "JSON Web Key Set" from well-known jwks_uri and returns the public key that corresponds with the access token kid value
674
- if (!this.helperRest) this.helperRest = this.newHelperRest()
675
- let res
676
- try { // get issuer and jwks_uri from well-knonw uri
677
- res = await this.helperRest.doRequest('undefined', 'GET', wellKnownUri)
678
- } catch (err: any) {
679
- logger.error(`${gwName}[${pluginName}] JWKS wellKnownUri=${wellKnownUri} error: ${err.message}`)
680
- return []
681
- }
682
- if (!res?.body) return []
683
- const issuer = res.body.issuer
684
- const jwks_uri = res.body.jwks_uri
685
- if (!issuer || !jwks_uri) {
686
- logger.error(`${gwName}[${pluginName}] JWKS wellKnownUri=${wellKnownUri} error: found issuer=${issuer} and jwks_uri=${jwks_uri} - both should be found`)
687
- return []
688
- }
689
- // JWKS
690
- try { // get jwks that correspods with kid from jwks_uri
691
- res = await this.helperRest.doRequest('undefined', 'GET', jwks_uri)
692
- } catch (err: any) {
693
- logger.error(`${gwName}[${pluginName}] JWKS jwks_uri=${jwks_uri} error: ${err.message}`)
694
- return []
695
- }
696
- if (!res || !Array.isArray(res?.body?.keys)) return []
697
- const keys = res.body.keys.filter((k: any) => k.kid === kid)
698
- if (keys.length !== 1) return []
699
- try {
700
- return [jwkToPem(keys[0]), issuer, keys[0].alg]
701
- } catch (err: any) {
702
- return []
703
- }
704
- }
705
-
706
- const jwtVerify = async (baseEntity: string, method: string, el: Record<string, any>, authToken: string, kid?: string): Promise<boolean> => { // used by bearerJwt
677
+ const jwtVerify = async (baseEntity: string, method: string, el: Record<string, any>, authToken: string): Promise<boolean> => { // used by bearerJwt
707
678
  try {
708
- jwt.verify(authToken, (el.secret) ? el.secret : el.publicKeyContent, el.options)
679
+ if (el.wellKnownUri) {
680
+ if (!el.jwks) {
681
+ if (!this.helperRest) this.helperRest = this.newHelperRest()
682
+ let res
683
+ try { // get issuer and jwks_uri from well-knonw uri
684
+ res = await this.helperRest.doRequest('undefined', 'GET', el.wellKnownUri)
685
+ } catch (err: any) {
686
+ throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: ${err.message}`)
687
+ }
688
+ if (!res?.body) throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: response missing data`)
689
+ const issuer = res.body.issuer
690
+ const jwks_uri = res.body.jwks_uri
691
+ if (!issuer || !jwks_uri) {
692
+ throw new Error(`JWKS wellKnownUri=${el.wellKnownUri} error: found issuer=${issuer} and jwks_uri=${jwks_uri} - both should be found`)
693
+ }
694
+ if (!el.options) el.options = {}
695
+ el.options.issuer = issuer
696
+ el.jwks = jose.createRemoteJWKSet(new URL(jwks_uri)) // will automatically reload the JWKS when verification fails due to an unknown kid
697
+ }
698
+ await jose.jwtVerify(authToken, el.jwks, el.options)
699
+ } else {
700
+ if (el.secret && !el.secretEncoded) {
701
+ el.secretEncoded = new TextEncoder().encode(el.secret)
702
+ if (!el.options) el.options = {}
703
+ el.options.algorithms = ['HS256', 'HS384', 'HS512'] // symmetric algorithms when using secret
704
+ }
705
+ await jose.jwtVerify(authToken, (el.secretEncoded) ? el.secretEncoded : el.publicKeyObj, el.options)
706
+ }
709
707
  if (Array.isArray(el?.baseEntities) && el.baseEntities.length > 0) {
710
708
  if (!el.baseEntities.includes(baseEntity)) return false
711
709
  }
712
710
  if (el.readOnly === true && method !== 'GET') return false
713
711
  return true // authorization OK
714
712
  } catch (err: any) {
715
- if (!el.wellKnownUri) throw new Error(`JWT error: ${err.message}`)
716
- // using wellKnownUri - JWKS and external public certificate - try once again we might have an updated certificate (key)
717
- if (!kid) throw new Error(`JWKS error: missing kid`)
718
- const [pemKey, issuer, alg] = await getJwksPemKey(kid, el.wellKnownUri)
719
- if (!pemKey) throw new Error('JWKS error: no external public certificate found')
720
- el.publicKeyContent = pemKey
721
- if (!el.options) el.options = {}
722
- if (issuer) el.options.issuer = issuer
723
- if (alg) el.options.algorithms = [alg]
724
- try {
725
- jwt.verify(authToken, el.publicKeyContent, el.options)
726
- if (Array.isArray(el?.baseEntities) && el.baseEntities.length > 0) {
727
- if (!el.baseEntities.includes(baseEntity)) return false
728
- }
729
- if (el.readOnly === true && method !== 'GET') return false
730
- return true // authorization OK
731
- } catch (err: any) {
732
- throw new Error(`JWKS error: ${err.message}`)
733
- }
713
+ throw new Error(`JWT error: ${err.message}`)
734
714
  }
735
715
  }
736
716
 
737
717
  const bearerJwt = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
738
- if (authType !== 'Bearer' || !found.BearerJwt) return false // no standard jwt bearer token
739
- const jtoken: any = jwt.decode(authToken, { complete: true })
740
- if (!jtoken) return false
741
- if (jtoken?.payload['iss'] && jtoken?.payload['iss'].indexOf('https://sts.windows.net') === 0) return false // azure - handled by bearerJwtAzure
718
+ if (!found.BearerJwt) return false
719
+ if (authType !== 'Bearer' || !authToken) return false
720
+ let payload
721
+ try {
722
+ payload = jose.decodeJwt(authToken)
723
+ if (!payload) return false
724
+ } catch (err: any) {
725
+ return false
726
+ }
727
+ if (payload.iss && payload.iss.indexOf('https://sts.windows.net') === 0) return false // azure - handled by bearerJwtAzure
728
+ if (found.BearerOAuth) {
729
+ const a = this.config.scimgateway.auth.bearerOAuth
730
+ const confObjs = a.filter((o: any) => o.clientId === payload.aud)
731
+ if (confObjs.length > 0) return false // jwt handled by bearerOauth
732
+ }
733
+ const errs: Array<string> = []
742
734
  const arr = this.config.scimgateway.auth.bearerJwt
743
735
  for (let i = 0; i < arr.length; i++) {
744
- if (await jwtVerify(baseEntity, method, arr[i], authToken, jtoken?.header?.kid) === true) return true
736
+ try {
737
+ if (await jwtVerify(baseEntity, method, arr[i], authToken) === true) return true
738
+ } catch (err: any) {
739
+ errs.push(err.message)
740
+ }
745
741
  }
746
- throw new Error('JWT authentication failed')
742
+ if (errs.length > 0) throw new Error(errs.join(' == NextConfigValidation ==> '))
743
+ return false
747
744
  }
748
745
 
749
746
  const bearerOAuth = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
750
- return await new Promise((resolve, reject) => { // bearer token
751
- if (authType !== 'Bearer' || !authToken) resolve(false)
752
- if (!found.BearerOAuth || !authToken) resolve(false)
747
+ return await new Promise(async (resolve, reject) => { // bearer token
748
+ if (!found.BearerOAuth) return resolve(false)
749
+ if (authType !== 'Bearer' || !authToken) return resolve(false)
753
750
  // this.config.scimgateway.auth.oauthTokenStore is autmatically generated by token create having syntax:
754
751
  // { this.config.scimgateway.auth.oauthTokenStore: <token>: { expireDate: <timestamp>, readOnly: <copy-from-config>, baseEntities: [ <copy-from-config> ], isTokenRequested: true }}
752
+ let payload
753
+ try {
754
+ payload = jose.decodeJwt(authToken)
755
+ if (!payload || payload.iss !== 'SCIM Gateway' || !payload.aud || !payload.sub) return resolve(false)
756
+ } catch (err: any) {
757
+ return resolve(false)
758
+ }
759
+
755
760
  const arr = this.config.scimgateway.auth.bearerOAuth
761
+ const confObjs = arr.filter((o: any) => o.clientId === payload.aud)
762
+ if (confObjs.length !== 1) return resolve(false)
763
+ try {
764
+ await jose.jwtVerify(authToken, new TextEncoder().encode(confObjs[0].clientSecret), { algorithms: ['HS256'] })
765
+ authToken = payload.sub
766
+ } catch (err: any) {
767
+ return resolve(false)
768
+ }
769
+
756
770
  if (this.config.scimgateway.auth.oauthTokenStore[authToken]) { // authentication OK
757
771
  const tokenObj = this.config.scimgateway.auth.oauthTokenStore[authToken]
758
772
  if (Date.now() > tokenObj.expireDate) {
@@ -763,10 +777,10 @@ export class ScimGateway {
763
777
  }
764
778
  if (tokenObj.baseEntities) {
765
779
  if (Array.isArray(tokenObj.baseEntities) && tokenObj.baseEntities.length > 0) {
766
- if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed for this bearerOAuth according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
780
+ if (!tokenObj.baseEntities.includes(baseEntity)) return reject(new Error(`baseEntity=${baseEntity} not allowed according to bearerOAuth configuration baseEntitites=${tokenObj.baseEntities}`))
767
781
  }
768
782
  }
769
- if (tokenObj.readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly for this bearerOAuth according to bearerOAuth configuration readOnly=true'))
783
+ if (tokenObj.readOnly === true && method !== 'GET') return reject(new Error('only allowing readOnly according to bearerOAuth configuration readOnly=true'))
770
784
  return resolve(true)
771
785
  } else {
772
786
  for (let i = 0; i < arr.length; i++) { // resolve if token memory store have been cleared because of a gateway restart
@@ -791,7 +805,7 @@ export class ScimGateway {
791
805
  }
792
806
  }
793
807
  }
794
- reject(new Error('OAuth authentication failed'))
808
+ resolve(false)
795
809
  })
796
810
  }
797
811
 
@@ -816,8 +830,10 @@ export class ScimGateway {
816
830
 
817
831
  const isAuthorized = async (ctx: Context): Promise<boolean> => { // authentication/authorization
818
832
  const [authType, authToken] = (ctx.request.headers.get('authorization') || '').split(' ') // [0] = 'Basic' or 'Bearer'
819
- try { // authenticate
820
- const arrResolve = await Promise.all([
833
+ let arrResolve: boolean[] = []
834
+ try {
835
+ // authenticate
836
+ arrResolve = await Promise.all([
821
837
  basic(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken, ctx.path),
822
838
  bearerToken(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
823
839
  bearerJwtAzure(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
@@ -825,22 +841,6 @@ export class ScimGateway {
825
841
  bearerOAuth(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
826
842
  authPassThrough(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken, ctx.path),
827
843
  ])
828
- .catch((err) => { throw (err) })
829
- for (const i in arrResolve) {
830
- if (arrResolve[i] === true) return true // auth OK - continue with routes
831
- }
832
- // all false - invalid auth method or missing pluging config
833
- let err: Error
834
- if (authType.length < 1) err = new Error(`${ctx.request.url} request is missing authentication information`)
835
- else {
836
- err = new Error(`${ctx.request.url} request having unsupported authentication or plugin configuration is missing`)
837
- logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] request authToken = ${authToken}`, { baseEntity: ctx?.routeObj?.baseEntity })
838
- logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] request jwt.decode(authToken) = ${JSON.stringify(jwt.decode(authToken))}`, { baseEntity: ctx?.routeObj?.baseEntity })
839
- }
840
- if (authType === 'Bearer') ctx.response.headers.set('WWW-Authenticate', 'Bearer realm=""')
841
- else if (found.Basic) ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
842
- if (ctx.request.url !== '/favicon.ico' && !ctx.request.url.startsWith('/apple-touch-icon')) logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
843
- return false
844
844
  } catch (err: any) {
845
845
  if (authType === 'Bearer') {
846
846
  let str = 'realm=""'
@@ -855,19 +855,30 @@ export class ScimGateway {
855
855
  ctx.response.body = JSON.stringify(errMsg)
856
856
  }
857
857
  }
858
- ctx.response.headers.set('WWW-Authenticate', `Bearer ${str}`)
859
- } else ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
860
- if (pwErrCount < 3) {
861
- pwErrCount += 1
862
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
863
- } else { // delay brute force attempts
864
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} ${err.message} => delaying response with 2 minutes to prevent brute force`, { baseEntity: ctx?.routeObj?.baseEntity })
865
- await new Promise((resolve) => {
866
- setTimeout(() => { resolve(null) }, 1000 * 60 * 2)
867
- })
868
- }
858
+ ctx.response.headers.set('www-authenticate', `Bearer ${str}`)
859
+ } else ctx.response.headers.set('www-authenticate', 'Basic realm=""')
860
+ logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
869
861
  return false
870
862
  }
863
+ for (const i in arrResolve) {
864
+ if (arrResolve[i] === true) return true // auth OK - continue with routes
865
+ }
866
+ // all auth validations failed
867
+ if (!authToken) {
868
+ if (found.Basic && ctx.request.headers.has('sec-fetch-dest')) ctx.response.headers.set('www-authenticate', 'Basic realm=""')
869
+ return false
870
+ }
871
+ if (authType === 'Bearer') ctx.response.headers.set('www-authenticate', 'Bearer realm=""')
872
+ else ctx.response.headers.set('www-authenticate', 'Basic realm=""')
873
+ if (pwErrCount < 3) pwErrCount += 1
874
+ else { // delay brute force attempts
875
+ const delay = (this.config.scimgateway.idleTimeout || 120) - 5
876
+ logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ctx.request.url} => max authentication failures reached, delaying response with ${delay} seconds to prevent brute force`, { baseEntity: ctx?.routeObj?.baseEntity })
877
+ await new Promise((resolve) => {
878
+ setTimeout(() => { resolve(null) }, 1000 * delay)
879
+ })
880
+ }
881
+ return false
871
882
  }
872
883
 
873
884
  const ipAllowList = (ipAddr: string): boolean => {
@@ -962,11 +973,66 @@ export class ScimGateway {
962
973
  )
963
974
  }
964
975
 
976
+ // oauth well-known: /oauth/.well-known/openid-configuration
977
+ // this.jwk is managed by helper-rest oauthJwtBearer - Entra ID Federated Identity
978
+ // {issuer: <scimgateway-baseUrl>/oauth, <federated-identity-unique-name>: {privateKey, publicKey}}
979
+ // example issuer: https://scimgateway.my-company.com/oauth
980
+ const getHandlerOauthWellKnown = async (ctx: Context) => {
981
+ const baseEntity = ctx.routeObj.baseEntity
982
+ logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] .well-known request`)
983
+
984
+ if (!this.jwk || !this.jwk[baseEntity] || !this.jwk[baseEntity].issuer) {
985
+ ctx.response.body = '{}'
986
+ ctx.response.status = 200
987
+ return ctx
988
+ }
989
+
990
+ const issuer = this.jwk[baseEntity].issuer // dynamic set by helper-rest oauthJwtBearer e.g. 'https://scimgateway.my-company.com/oauth'
991
+ let body = {
992
+ issuer,
993
+ jwks_uri: issuer + '/certs',
994
+ }
995
+ ctx.response.body = JSON.stringify(body)
996
+ ctx.response.status = 200
997
+ }
998
+
999
+ // oauth JWKS: /oauth/certs
1000
+ // this.jwk is managed by helper-rest oauthJwtBearer - Entra ID Federated Identity
1001
+ // {issuer: <scimgateway-baseUrl>/oauth, <federated-identity-unique-name>: {privateKey, publicKey}}
1002
+ const getHandlerOauthCerts = async (ctx: Context) => {
1003
+ const baseEntity = ctx.routeObj.baseEntity
1004
+ logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] jwks_uri certs request`)
1005
+
1006
+ if (!this.jwk || !this.jwk[baseEntity]) {
1007
+ ctx.response.body = '{"keys":[]}'
1008
+ ctx.response.status = 200
1009
+ return ctx
1010
+ }
1011
+
1012
+ const keys: Array<Record<string, any>> = []
1013
+ for (const name in this.jwk[baseEntity]) {
1014
+ const keyObj = this.jwk[baseEntity][name]
1015
+ if (typeof keyObj !== 'object' || keyObj === null) continue // skip issuer
1016
+ const jwk = await jose.exportJWK(this.jwk[baseEntity][name].publicKey)
1017
+ jwk.kid = createHash('sha256') // needed for JWKS
1018
+ .update(JSON.stringify(jwk))
1019
+ .digest('base64url')
1020
+ keys.push(jwk)
1021
+ }
1022
+
1023
+ let body = {
1024
+ keys,
1025
+ }
1026
+ ctx.response.body = JSON.stringify(body)
1027
+ ctx.response.status = 200
1028
+ }
1029
+
965
1030
  // oauth token request, POST /oauth/token
966
1031
  const postHandlerOauthToken = async (ctx: Context) => {
967
- logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] token request`, { baseEntity: ctx?.routeObj?.baseEntity })
1032
+ const baseEntity = ctx.routeObj.baseEntity
1033
+ logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] token request`)
968
1034
  if (!found.BearerOAuth) {
969
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] token request, but plugin is missing auth.bearerOAuth configuration`, { baseEntity: ctx?.routeObj?.baseEntity })
1035
+ logger.error(`${gwName}[${pluginName}][${baseEntity}] [oauth] token request, but plugin is missing auth.bearerOAuth configuration`)
970
1036
  ctx.response.status = 500
971
1037
  return
972
1038
  }
@@ -974,7 +1040,7 @@ export class ScimGateway {
974
1040
  try {
975
1041
  if (!jsonBody) throw new Error('missing body')
976
1042
  if (typeof jsonBody !== 'object') { // might have application/x-www-form-urlencoded or multipart/form-data body, but incorrect Content-Type header
977
- logger.debug(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] continue request validation even though incorrect body vs header Content-Type: ${ctx.request.headers.get('content-type')}`, { baseEntity: ctx?.routeObj?.baseEntity })
1043
+ logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] continue request validation even though incorrect body vs header Content-Type: ${ctx.request.headers.get('content-type')}`)
978
1044
  let body = utils.formUrlEncodedToJSON(jsonBody)
979
1045
  if (Object.keys(body).length < 1) {
980
1046
  body = utils.formDataMultipartToJSON(jsonBody)
@@ -985,7 +1051,7 @@ export class ScimGateway {
985
1051
  }
986
1052
  jsonBody = utils.copyObj(jsonBody) // no changes to original
987
1053
  } catch (err: any) {
988
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] token request error: ${err.message}`, { baseEntity: ctx?.routeObj?.baseEntity })
1054
+ logger.error(`${gwName}[${pluginName}][${baseEntity}] [oauth] token request error: ${err.message}`)
989
1055
  ctx.response.status = 401
990
1056
  return
991
1057
  }
@@ -1016,6 +1082,9 @@ export class ScimGateway {
1016
1082
  for (let i = 0; i < arr.length; i++) {
1017
1083
  if (!arr[i].clientId || !arr[i].clientSecret) continue
1018
1084
  if (arr[i].clientId === jsonBody.client_id && arr[i].clientSecret === jsonBody.client_secret) { // authentication OK
1085
+ if (Array.isArray(arr[i].baseEntities) && arr[i].baseEntities.length > 0) {
1086
+ if (!arr[i].baseEntities.includes(baseEntity)) continue
1087
+ }
1019
1088
  token = utils.getEncrypted(jsonBody.client_secret, jsonBody.client_secret)
1020
1089
  baseEntities = utils.copyObj(arr[i].baseEntities)
1021
1090
  if (arr[i].readOnly && arr[i].readOnly === true) readOnly = true
@@ -1027,7 +1096,7 @@ export class ScimGateway {
1027
1096
  }
1028
1097
  if (!token) {
1029
1098
  err = 'invalid_client'
1030
- errDescr = 'incorrect or missing client_id/client_secret'
1099
+ errDescr = 'incorrect or missing client_id/client_secret or baseEntity'
1031
1100
  if (pwErrCount < 3) {
1032
1101
  pwErrCount += 1
1033
1102
  } else { // delay brute force attempts
@@ -1068,11 +1137,26 @@ export class ScimGateway {
1068
1137
  baseEntities,
1069
1138
  }
1070
1139
 
1140
+ const jwtPayload: jose.JWTPayload = {
1141
+ iss: 'SCIM Gateway',
1142
+ aud: jsonBody.client_id,
1143
+ sub: token,
1144
+ iat: Math.floor(Date.now() / 1000) - 60,
1145
+ exp: Math.floor(Date.now() / 1000) + expires,
1146
+ }
1147
+ const jwtHeaders = {
1148
+ alg: 'HS256',
1149
+ typ: 'JWT',
1150
+ }
1151
+ const jwt = await new jose.SignJWT(jwtPayload)
1152
+ .setProtectedHeader(jwtHeaders)
1153
+ .sign(new TextEncoder().encode(jsonBody.client_secret))
1154
+
1071
1155
  const tx = {
1072
- access_token: token,
1156
+ access_token: jwt,
1073
1157
  token_type: 'Bearer',
1074
1158
  expires_in: expires,
1075
- refresh_token: token, // ignored by scimgateway, but maybe used by client
1159
+ refresh_token: jwt, // ignored by scimgateway, but maybe used by client
1076
1160
  }
1077
1161
 
1078
1162
  ctx.response.headers.set('Cache-Control', 'no-store')
@@ -2507,11 +2591,15 @@ export class ScimGateway {
2507
2591
  baseEntity = 'undefined'
2508
2592
  }
2509
2593
  if (handle) handle = handle.toLowerCase()
2510
- if (!handlers.includes(handle) || rest) { // rest => too many path elements
2594
+ if (!handlers.includes(handle)) {
2511
2595
  baseEntity = ''
2512
2596
  handle = ''
2513
2597
  id = ''
2514
2598
  rest = ''
2599
+ } else if (rest) { // too many path elements - keep baseEntity only
2600
+ handle = ''
2601
+ id = ''
2602
+ rest = ''
2515
2603
  }
2516
2604
 
2517
2605
  // bodyParser
@@ -2585,9 +2673,19 @@ export class ScimGateway {
2585
2673
  return ctx
2586
2674
  }
2587
2675
  }
2676
+ if (ctx.request.method === 'GET' && ctx.path.endsWith('/oauth/.well-known/openid-configuration')) {
2677
+ await getHandlerOauthWellKnown(ctx)
2678
+ if (!ctx.response.status) ctx.response.status = 404
2679
+ return ctx
2680
+ }
2681
+ if (ctx.request.method === 'GET' && ctx.path.endsWith('/oauth/certs')) {
2682
+ await getHandlerOauthCerts(ctx)
2683
+ if (!ctx.response.status) ctx.response.status = 404
2684
+ return ctx
2685
+ }
2588
2686
 
2589
2687
  // validation
2590
- if (ctx.request.method === 'POST' && ctx.path === '/oauth/token') {
2688
+ if (ctx.request.method === 'POST' && ctx.path.endsWith('/oauth/token')) {
2591
2689
  await postHandlerOauthToken(ctx)
2592
2690
  if (!ctx.response.status) ctx.response.status = 401 // Unauthorized
2593
2691
  } else if (!ctx.routeObj.handle) {
@@ -3173,26 +3271,22 @@ export class ScimGateway {
3173
3271
  logger.info(`${gwName}[${pluginName}] now stopping...`)
3174
3272
  await logger.close()
3175
3273
  if (server) {
3176
- if (typeof Bun !== 'undefined') {
3274
+ if (typeof server.stop === 'function') { // Bun
3177
3275
  server.stop(true)
3178
- }
3179
- }
3180
- if (server) {
3181
- if (typeof Bun !== 'undefined') {
3182
3276
  await Bun.sleep(400) // give in-flight requests a chance to complete, also plugins may use SIGTERM/SIGINT
3183
3277
  server.stop()
3184
3278
  process.exit(0)
3185
- } else {
3186
- server.close(function () {
3187
- setTimeout(function () { // plugins may also use SIGTERM/SIGINT
3279
+ } else if (typeof server.close === 'function') { // Node.js
3280
+ server.close(() => {
3281
+ setTimeout(() => { // plugins may use SIGTERM/SIGINT
3188
3282
  process.exit(0)
3189
3283
  }, 0.5 * 1000)
3190
3284
  })
3191
- setTimeout(function () { // problem closing server connections in time due to keep-alive sessions (active browser connection?), now forcing exit
3192
- process.exit(1)
3193
- }, 2 * 1000)
3194
3285
  }
3195
3286
  }
3287
+ setTimeout(() => { // safety net
3288
+ process.exit(1)
3289
+ }, 2 * 1000)
3196
3290
  }
3197
3291
 
3198
3292
  process.setMaxListeners(Infinity)
@@ -3612,8 +3706,9 @@ Content-Transfer-Encoding: quoted-printable
3612
3706
  keyFile = dotConfig[key]
3613
3707
  }
3614
3708
  dotConfig[key] = keyFile
3615
- const addKey = key.replace(`.${lastKey}`, '.publicKeyContent')
3616
- dotConfig[addKey] = fs.readFileSync(keyFile)
3709
+ const addKey = key.replace(`.${lastKey}`, '.publicKeyObj')
3710
+ const pem = fs.readFileSync(keyFile)
3711
+ dotConfig[addKey] = createPublicKey(pem)
3617
3712
  } else if (key.endsWith('.serviceAccountKeyFile')) { // Google Service Account Key json-file
3618
3713
  let keyFile = path.join(this.configDir, '/certs/', dotConfig[key])
3619
3714
  if (dotConfig[key].startsWith('/') || dotConfig[key].includes('\\')) {
package/lib/utils.ts CHANGED
@@ -673,8 +673,11 @@ export const formDataMultipartToJSON = function (body?: string, boundary?: strin
673
673
  */
674
674
  export const getBase64CertificateThumbprint = function (pemCertContent: string, shaVersion: 'sha1' | 'sha256' = 'sha1'): string {
675
675
  if (!pemCertContent) return ''
676
- const certMatch = pemCertContent.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/)
677
- if (!certMatch) return ''
676
+ let certMatch = pemCertContent.match(/-----BEGIN CERTIFICATE-----([\s\S]+?)-----END CERTIFICATE-----/)
677
+ if (!certMatch) {
678
+ certMatch = pemCertContent.match(/-----BEGIN PUBLIC KEY-----([\s\S]+?)-----END PUBLIC KEY-----/)
679
+ if (!certMatch) return ''
680
+ }
678
681
  const certBase64 = certMatch[1].replace(/\s+/g, '') // remove whitespace and newlines
679
682
  const certDer = Buffer.from(certBase64, 'base64') // decode the PEM to DER (Base64 decode)
680
683
  const hash = crypto.createHash(shaVersion).update(certDer).digest() // compute the SHA-256 hash of the DER
@@ -696,9 +699,9 @@ export const getBase64CertificateThumbprint = function (pemCertContent: string,
696
699
  export const getEtag = function (obj: Record<string, any>): string {
697
700
  if (typeof obj !== 'object' || obj === null) return ''
698
701
  const hash = crypto
699
- .createHash('md5')
702
+ .createHash('sha256')
700
703
  .update(JSON.stringify(obj), 'utf8')
701
- .digest('base64')
704
+ .digest('base64url')
702
705
  .substring(0, 22)
703
706
 
704
707
  let eTag = ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.4.4",
3
+ "version": "5.5.0",
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)",
@@ -47,8 +47,7 @@
47
47
  "https-proxy-agent": "^7.0.6",
48
48
  "hyco-https": "^1.4.5",
49
49
  "is-in-subnet": "^4.0.1",
50
- "jsonwebtoken": "^9.0.2",
51
- "jwk-to-pem": "^2.0.7",
50
+ "jose": "^6.0.11",
52
51
  "ldapjs": "^3.0.7",
53
52
  "lokijs": "^1.5.12",
54
53
  "mongodb": "^6.16.0",
@@ -62,11 +61,10 @@
62
61
  "typescript": "^5.6.3"
63
62
  },
64
63
  "devDependencies": {
65
- "@stylistic/eslint-plugin": "^4.4.0",
64
+ "@stylistic/eslint-plugin": "^5.1.0",
66
65
  "@types/bun": "latest",
67
66
  "@types/dot-object": "^2.1.6",
68
67
  "@types/jsonwebtoken": "^9.0.9",
69
- "@types/jwk-to-pem": "^2.0.3",
70
68
  "@types/node": "latest",
71
69
  "@types/nodemailer": "^6.4.17",
72
70
  "@types/passport": "^1.0.17",