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 +63 -2
- package/bun.lock +5 -25
- package/lib/helper-rest.ts +162 -82
- package/lib/logger.ts +1 -1
- package/lib/scimgateway.ts +235 -140
- package/lib/utils.ts +7 -4
- package/package.json +3 -5
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
|
|
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": "
|
|
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
|
-
"
|
|
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": "^
|
|
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@
|
|
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=="],
|
package/lib/helper-rest.ts
CHANGED
|
@@ -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:
|
|
152
|
-
let
|
|
152
|
+
let jwtClaims: jose.JWTPayload | Record<string, any>
|
|
153
|
+
let jwtHeaders: jose.JWTHeaderParameters
|
|
153
154
|
|
|
154
155
|
if (tenantIdGUID) { // Microsoft Entra ID
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
'
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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:
|
|
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 (!
|
|
414
|
-
|
|
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
|
-
|
|
713
|
-
|
|
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('"?|') + '"?'
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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
|
|
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 ===
|
|
589
|
-
logger.
|
|
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 (
|
|
600
|
-
if (!
|
|
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
|
-
|
|
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 (
|
|
626
|
-
if (!
|
|
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
|
-
|
|
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 (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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('
|
|
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 (
|
|
752
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
|
|
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('
|
|
859
|
-
} else ctx.response.headers.set('
|
|
860
|
-
|
|
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
|
-
|
|
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}][${
|
|
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}][${
|
|
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}][${
|
|
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:
|
|
1156
|
+
access_token: jwt,
|
|
1073
1157
|
token_type: 'Bearer',
|
|
1074
1158
|
expires_in: expires,
|
|
1075
|
-
refresh_token:
|
|
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)
|
|
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
|
|
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
|
|
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(
|
|
3187
|
-
setTimeout(
|
|
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}`, '.
|
|
3616
|
-
|
|
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
|
-
|
|
677
|
-
if (!certMatch)
|
|
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('
|
|
702
|
+
.createHash('sha256')
|
|
700
703
|
.update(JSON.stringify(obj), 'utf8')
|
|
701
|
-
.digest('
|
|
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.
|
|
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
|
-
"
|
|
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": "^
|
|
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",
|