scimgateway 5.4.2 → 5.4.4

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
+ - External JWKS (JSON Web Key Set) is now supported by JWT Authentication. These are public and typically frequent rotated by modern identity providers
19
20
  - [Azure Relay](https://learn.microsoft.com/en-us/azure/azure-relay/relay-what-is-it) is now supported for secure and hassle-free outbound communication — with just one minute of configuration
20
21
  - [ETag](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14) is now supported
21
22
  - [Bulk Operations](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) is now supported
@@ -394,7 +395,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
394
395
 
395
396
  - **log.loglevel.console** - off, debug, info, warn or error. Default off. Output to stdout and errors to stderr
396
397
 
397
- - **log.loglevel.push** - off, debug, info, warn or error. Default info. Push to stream used by remote real-time log subscription
398
+ - **log.loglevel.push** - debug, info, warn or error. Default info. Push to stream used by remote real-time log subscription
398
399
 
399
400
  - **log.logDirectory** - custom defined log directory e.g. `/var/log/scimgateway` that will override default `<scimgateway path>/logs`. If not exist it will be created.
400
401
 
@@ -417,7 +418,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
417
418
 
418
419
  - **auth.bearerJwtAzure** - Array of one or more JWT used by Azure SyncFabric. **tenantIdGUID** must be set to Entra ID Tenant ID.
419
420
 
420
- - **auth.bearerJwt** - Array of one or more standard JWT objects. Using **secret** or **publicKey** for signature verification. publicKey should be set to the filename of public key or certificate pem-file located in `<package-root>\config\certs` or absolute path being used. Clear text secret will become encrypted when gateway is started. **options.issuer** is mandatory. Other options may also be included according to jsonwebtoken npm package definition.
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.
421
422
 
422
423
  - **auth.bearerOAuth** - Array of one or more Client Credentials OAuth configuration objects. **`clientId`** and **`clientSecret`** are mandatory. clientSecret value will become encrypted when gateway is started. OAuth token request url is **/oauth/token** e.g. `http://localhost:8880/oauth/token`
423
424
 
@@ -746,13 +747,18 @@ Please see code editor method HelperRest doRequest() IntelliSense for type and o
746
747
  ### Configuration notes - Remote real-time log subscription
747
748
  Using remote real-time log subscription we may implement custom logic like monitoring and centralized logging
748
749
 
749
- - using browser and url: https://host/logger
750
- - curl -Ns https://host/logger -u gwread:password | sed 's/\xE2\x80\x8B//g'
751
- - curl -Ns https://host/logger -H "Authorization: Bearer secret" | sed 's/\xE2\x80\x8B//g'
752
- (-s and sed to ignore keep-alive character)
750
+ - browser and url: https://host/logger
751
+ - curl with -u or -H "Authorization: Bearer secret"
752
+ ```
753
+ curl -Ns http://localhost:8880/logger -u gwadmin:password | awk '
754
+ /^data: / {sub(/^data: /,""); printf "%s", $0; last=1; next}
755
+ /^$/ {if (last) print ""; last=0}
756
+ '
757
+ ```
753
758
  - custom client API (see example below)
754
759
  - not supported by Azure Relay
755
760
 
761
+
756
762
  We may configure read-only user/secret for log collection purpose
757
763
 
758
764
  "auth": {
@@ -792,67 +798,57 @@ Example using debug loglevel:
792
798
  Example code implementing remote real-time log subscription and custom message handling
793
799
 
794
800
  ```
795
- // startup: bun <scriptname.ts>
796
- // update url (ws or wss) and the auth according to environment used
797
- const url = 'ws://localhost:8880/logger'
798
- const auth = 'Basic ' + btoa('gwadmin' + ':' + 'password') // const auth = 'Bearer ' + 'secret'
799
-
800
- const tls: any = {}
801
- if (url.startsWith('wss:')) {
802
- tls.ca = [Bun.file('/path/to/self-signed-cert.pem')], // only needed for self-signed certs
803
- tls.rejectUnauthorized = false
804
- }
805
-
806
- // messageHandler implements message handling and custom logic
807
- // could also use JSON.parse(message) and granular filtering on log "level"
801
+ //
802
+ // usage: bun <scriptname.ts>
803
+ // update url and the auth according to environment used
804
+ //
805
+ const username = "gwadmin"
806
+ const password = "password"
807
+ const url = "http://localhost:8880/logger"
808
+
809
+ const headers = new Headers({
810
+ Authorization: "Basic " + btoa(`${username}:${password}`),
811
+ Accept: "text/event-stream"
812
+ })
813
+
814
+ // message handling and custom logic
815
+ // we could also do JSON.parse(message) and granular filtering on log "level"
808
816
  const messageHandler = async (message: string) => {
809
817
  console.log(message)
810
818
  }
811
819
 
812
- const startWebSocket = async () => {
813
- try {
814
- const ws = new WebSocket(url, {
815
- headers: {
816
- Authorization: auth,
817
- },
818
- tls,
819
- })
820
-
821
- // message is received
822
- ws.addEventListener("message", event => {
823
- messageHandler(event.data)
824
- })
825
-
826
- // socket opened
827
- ws.addEventListener("open", event => {
820
+ async function startup() {
821
+ while (true) {
822
+ try {
823
+ const resp = await fetch(url, { headers });
824
+ if (!resp.ok || !resp.body) {
825
+ console.error(`❌ Response error: ${resp.status} ${resp.statusText}`)
826
+ await Bun.sleep(10_000)
827
+ continue
828
+ }
828
829
  console.log('✅ Now awaiting log events...\n')
829
- })
830
-
831
- // socket closed
832
- ws.addEventListener("close", event => {
833
- let addInfo = ''
834
- if (event.code === 1002) addInfo = ' => most likely authentication failure?'
835
- console.warn(`⚠️ Connection closed (${event.code}): ${event.reason || 'no reason'}${addInfo}`)
836
- retry()
837
- })
838
-
839
- // error handler
840
- ws.addEventListener("error", event => {
841
- // console.error('❌ WebSocket error:', event.message)
842
- })
843
-
844
- } catch (err: any) {
845
- console.error('❌ Unexpected error:', err)
846
- }
847
- }
848
830
 
849
- const retry = async () => {
850
- console.log('🔁 Retry in 10 seconds...')
851
- await Bun.sleep(10 * 1000)
852
- startWebSocket()
831
+ const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader()
832
+
833
+ while (true) {
834
+ const { value, done } = await reader.read()
835
+ if (done) break
836
+ if (!value.startsWith('data: ')) continue
837
+ const i = value.indexOf("\n\n")
838
+ if (i < 1) continue
839
+ const msg = value.slice(6, i)
840
+ messageHandler(msg)
841
+ }
842
+ console.error("⚠️ Connection closed");
843
+ await Bun.sleep(10_000)
844
+ } catch (err: any) {
845
+ console.error("❌ Connection error:", err?.message || err)
846
+ await Bun.sleep(10_000)
847
+ }
848
+ }
853
849
  }
854
850
 
855
- startWebSocket()
851
+ startup()
856
852
  ```
857
853
 
858
854
  ### Configuration notes - Azure Relay
@@ -1471,7 +1467,39 @@ In code editor (e.g., Visual Studio Code), method details and documentation are
1471
1467
  MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1472
1468
 
1473
1469
 
1474
- ## Change log
1470
+ ## Change log
1471
+
1472
+ ### v5.4.4
1473
+
1474
+ [Improved]
1475
+
1476
+ - External JWKS (JSON Web Key Set) is now supported by JWT Authentication. These are public and typically frequent rotated by modern identity providers
1477
+
1478
+ JKWS is enabled by setting scimgateway.auth.bearerJwt[].wellKnownUri to the identity provider's well-known URI
1479
+
1480
+ Keycloak example:
1481
+
1482
+ auth: {
1483
+ "bearerJwt": [
1484
+ {
1485
+ "wellKnownUri": "https://keycloak.example.com/realms/example-realm/.well-known/openid-configuration",
1486
+ "options": {
1487
+ ...
1488
+ },
1489
+ ...
1490
+ }
1491
+ ]
1492
+ }
1493
+
1494
+ ### v5.4.3
1495
+
1496
+ [Fixed]
1497
+
1498
+ - helper-rest, fixed an issue introduced in v5.3.8 that caused problems using OAuth
1499
+
1500
+ [Improved]
1501
+
1502
+ - Remote real-time logger
1475
1503
 
1476
1504
  ### v5.4.2
1477
1505
 
package/bun.lock CHANGED
@@ -17,6 +17,7 @@
17
17
  "hyco-https": "^1.4.5",
18
18
  "is-in-subnet": "^4.0.1",
19
19
  "jsonwebtoken": "^9.0.2",
20
+ "jwk-to-pem": "^2.0.7",
20
21
  "ldapjs": "^3.0.7",
21
22
  "lokijs": "^1.5.12",
22
23
  "mongodb": "^6.16.0",
@@ -31,6 +32,7 @@
31
32
  "@types/bun": "latest",
32
33
  "@types/dot-object": "^2.1.6",
33
34
  "@types/jsonwebtoken": "^9.0.9",
35
+ "@types/jwk-to-pem": "^2.0.3",
34
36
  "@types/node": "latest",
35
37
  "@types/nodemailer": "^6.4.17",
36
38
  "@types/passport": "^1.0.17",
@@ -163,6 +165,8 @@
163
165
 
164
166
  "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
165
167
 
168
+ "@types/jwk-to-pem": ["@types/jwk-to-pem@2.0.3", "", {}, "sha512-I/WFyFgk5GrNbkpmt14auGO3yFK1Wt4jXzkLuI+fDBNtO5ZI2rbymyGd6bKzfSBEuyRdM64ZUwxU1+eDcPSOEQ=="],
169
+
166
170
  "@types/ldapjs": ["@types/ldapjs@3.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q=="],
167
171
 
168
172
  "@types/lokijs": ["@types/lokijs@1.5.14", "", {}, "sha512-4Fic47BX3Qxr8pd12KT6/T1XWU8dOlJBIp1jGoMbaDbiEvdv50rAii+B3z1b/J2pvMywcVP+DBPGP5/lgLOKGA=="],
@@ -235,6 +239,8 @@
235
239
 
236
240
  "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
237
241
 
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
+
238
244
  "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
239
245
 
240
246
  "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -253,10 +259,14 @@
253
259
 
254
260
  "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=="],
255
261
 
262
+ "bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
263
+
256
264
  "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
257
265
 
258
266
  "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
259
267
 
268
+ "brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
269
+
260
270
  "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
261
271
 
262
272
  "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -315,6 +325,8 @@
315
325
 
316
326
  "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
317
327
 
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
+
318
330
  "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
319
331
 
320
332
  "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -399,8 +411,12 @@
399
411
 
400
412
  "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
401
413
 
414
+ "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
415
+
402
416
  "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
403
417
 
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
+
404
420
  "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
405
421
 
406
422
  "https": ["https@1.0.0", "", {}, "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="],
@@ -463,6 +479,8 @@
463
479
 
464
480
  "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=="],
465
481
 
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
+
466
484
  "jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
467
485
 
468
486
  "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -507,6 +525,10 @@
507
525
 
508
526
  "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
509
527
 
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
+
510
532
  "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
511
533
 
512
534
  "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -50,6 +50,7 @@
50
50
  {
51
51
  "secret": null,
52
52
  "publicKey": null,
53
+ "wellKnownUri": null,
53
54
  "options": {
54
55
  "issuer": null
55
56
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -44,6 +44,7 @@
44
44
  {
45
45
  "secret": null,
46
46
  "publicKey": null,
47
+ "wellKnownUri": null,
47
48
  "options": {
48
49
  "issuer": null
49
50
  },
@@ -578,7 +578,7 @@ export class HelperRest {
578
578
  options.headers['Authorization'] = 'Basic ' + Buffer.from(`${o.auth?.options?.username}:${o.auth?.options?.password}`).toString('base64')
579
579
  delete o.auth
580
580
  }
581
- options = utils.extendObj(o?.connection?.options, options)
581
+ options = utils.extendObj(options, o)
582
582
  }
583
583
 
584
584
  const cli: any = {}
@@ -21,6 +21,7 @@ import dot from 'dot-object'
21
21
  import nodemailer from 'nodemailer'
22
22
  import fs from 'node:fs'
23
23
  import path from 'node:path'
24
+ import jwkToPem from 'jwk-to-pem'
24
25
  import * as jwt from 'jsonwebtoken'
25
26
  import * as utils from './utils.ts'
26
27
  import * as utilsScim from './utils-scim.ts'
@@ -37,7 +38,6 @@ export class ScimGateway {
37
38
  private getMemberOf: any
38
39
  private getAppRoles: any
39
40
  private pub: any
40
- private Nonce = new utils.TimerMapCache(2000)
41
41
  // @ts-expect-error: has no initializer
42
42
  private helperRest: HelperRest
43
43
  /** pluginName is the name of plugin e.g., plugin-loki */
@@ -670,37 +670,78 @@ export class ScimGateway {
670
670
  })
671
671
  }
672
672
 
673
- const jwtVerify = async (baseEntity: string, method: string, el: Record<string, any>, authToken: string) => { // used by bearerJwt
674
- return await new Promise((resolve) => {
675
- jwt.verify(authToken, (el.secret) ? el.secret : el.publicKeyContent, el.options, (err) => {
676
- if (err != null) resolve(false)
677
- else {
678
- if (el.baseEntities) {
679
- if (Array.isArray(el.baseEntities) && el.baseEntities.length > 0) {
680
- if (!baseEntity) return resolve(false)
681
- if (!el.baseEntities.includes(baseEntity)) return resolve(false)
682
- }
683
- }
684
- if (el.readOnly === true && method !== 'GET') return resolve(false)
685
- resolve(true) // authorization OK
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
707
+ try {
708
+ jwt.verify(authToken, (el.secret) ? el.secret : el.publicKeyContent, el.options)
709
+ if (Array.isArray(el?.baseEntities) && el.baseEntities.length > 0) {
710
+ if (!el.baseEntities.includes(baseEntity)) return false
711
+ }
712
+ if (el.readOnly === true && method !== 'GET') return false
713
+ return true // authorization OK
714
+ } 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
686
728
  }
687
- })
688
- })
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
+ }
734
+ }
689
735
  }
690
736
 
691
737
  const bearerJwt = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
692
738
  if (authType !== 'Bearer' || !found.BearerJwt) return false // no standard jwt bearer token
693
739
  const jtoken: any = jwt.decode(authToken, { complete: true })
694
- if (jtoken == null) return false
740
+ if (!jtoken) return false
695
741
  if (jtoken?.payload['iss'] && jtoken?.payload['iss'].indexOf('https://sts.windows.net') === 0) return false // azure - handled by bearerJwtAzure
696
- const promises: any = []
697
742
  const arr = this.config.scimgateway.auth.bearerJwt
698
743
  for (let i = 0; i < arr.length; i++) {
699
- promises.push(jwtVerify(baseEntity, method, arr[i], authToken))
700
- }
701
- const arrResolve = await Promise.all(promises).catch((err) => { throw (err) })
702
- for (const i in arrResolve) {
703
- if (arrResolve[i]) return true
744
+ if (await jwtVerify(baseEntity, method, arr[i], authToken, jtoken?.header?.kid) === true) return true
704
745
  }
705
746
  throw new Error('JWT authentication failed')
706
747
  }
@@ -786,7 +827,7 @@ export class ScimGateway {
786
827
  ])
787
828
  .catch((err) => { throw (err) })
788
829
  for (const i in arrResolve) {
789
- if (arrResolve[i]) return true // auth OK - continue with routes
830
+ if (arrResolve[i] === true) return true // auth OK - continue with routes
790
831
  }
791
832
  // all false - invalid auth method or missing pluging config
792
833
  let err: Error
@@ -827,7 +868,6 @@ export class ScimGateway {
827
868
  }
828
869
  return false
829
870
  }
830
- return false
831
871
  }
832
872
 
833
873
  const ipAllowList = (ipAddr: string): boolean => {
@@ -878,23 +918,24 @@ export class ScimGateway {
878
918
  const getHandlerLoggerSSE = async (ctx: Context) => {
879
919
  const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
880
920
  const encoder = new TextEncoder()
921
+ logger.info(`${gwName}[${pluginName}] remote logger connected from ip address ${ctx.ip}`, { baseEntity: ctx?.routeObj?.baseEntity })
881
922
 
882
923
  return new Response(
883
924
  new ReadableStream({
884
925
  start(controller) {
885
- controller.enqueue(encoder.encode(`\u200B`))
926
+ controller.enqueue(encoder.encode(`: keep-alive\n\n`))
886
927
 
887
928
  const sub = async (msgObj: Record<string, any>) => {
888
929
  if (logger.levelToInt(msgObj.level) < levelInt) return
889
930
  if (ctx?.routeObj?.baseEntity !== 'undefined') { // if using baseEntity e.g. <host>/company1/logger, only include corresponding baseEntity logentries
890
931
  if (ctx?.routeObj?.baseEntity !== msgObj.baseEntity) return
891
932
  }
892
- controller.enqueue(encoder.encode(`${JSON.stringify(msgObj)}\n`))
933
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(msgObj)}\n\n`))
893
934
  }
894
935
  logger.subscribe(sub)
895
936
 
896
937
  const keepAliveInterval = setInterval(() => {
897
- controller.enqueue(encoder.encode(`\u200B`)) // invisible keep-alive
938
+ controller.enqueue(encoder.encode(`: keep-alive\n\n`))
898
939
  }, 10000)
899
940
 
900
941
  const cleanup = () => {
@@ -2705,15 +2746,39 @@ export class ScimGateway {
2705
2746
  const isPublisherEnabled = this.config.scimgateway.stream.publisher.enabled
2706
2747
  const isChainingEnabled = this.config.scimgateway.chainingBaseUrl
2707
2748
 
2708
- const wssInit = `
2749
+ const sseInit = `
2709
2750
  <!DOCTYPE html>
2710
2751
  <html>
2711
2752
  <head>
2712
2753
  <style>
2754
+ html, body {
2755
+ height: 100%;
2756
+ margin: 0;
2757
+ padding: 0;
2758
+ }
2759
+ body {
2760
+ display: flex;
2761
+ flex-direction: column;
2762
+ height: 100vh;
2763
+ margin-left: 8px;
2764
+ }
2713
2765
  .header-flex {
2714
2766
  display: flex;
2715
2767
  align-items: center;
2716
2768
  gap: 16px;
2769
+ flex-shrink: 0;
2770
+ margin-top: 2px;
2771
+ margin-bottom: 2px;
2772
+ }
2773
+ #log {
2774
+ flex: 1 1 auto;
2775
+ width: 100%;
2776
+ overflow: auto;
2777
+ white-space: pre;
2778
+ margin: 0;
2779
+ min-height: 0;
2780
+ height: auto;
2781
+ box-sizing: border-box;
2717
2782
  }
2718
2783
  #stopBtn {
2719
2784
  padding: 4px 18px;
@@ -2728,42 +2793,41 @@ export class ScimGateway {
2728
2793
  </head>
2729
2794
  <body>
2730
2795
  <div class="header-flex">
2731
- <h3 style="margin:0;">SCIM Gateway remote logger</h3>
2796
+ <h3>SCIM Gateway remote logger</h3>
2732
2797
  <button id="stopBtn" type="button">Stop</button>
2733
2798
  </div>
2734
2799
  <pre id="log"></pre>
2735
2800
  <script>
2736
2801
  const stopBtn = document.getElementById('stopBtn')
2737
2802
  const logElem = document.getElementById('log')
2738
- let ws = new WebSocket('{{protocol}}//' + location.host + location.pathname)
2739
- ws.onmessage = function(event) {
2740
- event.data.split('\\n').forEach(function(line) {
2741
- if (!line.trim()) return
2742
- const htmlLine = line.replace(
2743
- /(level":"\\s*)(debug|info|warn|error)/i,
2744
- function(match, p1, p2) {
2745
- let color = ''
2746
- switch (p2.toLowerCase()) {
2747
- case 'debug': color = '#888'; break
2748
- case 'info': color = 'blue'; break
2749
- case 'warn': color = 'orange'; break
2750
- case 'error': color = 'red'; break
2751
- default: color = 'black'
2752
- }
2753
- return p1 + '<span style="color:' + color + ';font-weight:bold">' + p2 + '</span>'
2803
+ let es = new EventSource(location.pathname)
2804
+
2805
+ es.onmessage = function(event) {
2806
+ if (!event.data.trim()) return
2807
+ const htmlLine = event.data.replace(
2808
+ /(level":"\s*)(debug|info|warn|error)/i,
2809
+ function(match, p1, p2) {
2810
+ let color = ''
2811
+ switch (p2.toLowerCase()) {
2812
+ case 'debug': color = '#888'; break
2813
+ case 'info': color = 'blue'; break
2814
+ case 'warn': color = 'orange'; break
2815
+ case 'error': color = 'red'; break
2816
+ default: color = 'black'
2754
2817
  }
2755
- );
2756
- logElem.innerHTML += htmlLine + '<br>'
2757
- })
2818
+ return p1 + '<span style="color:' + color + ';font-weight:bold">' + p2 + '</span>'
2819
+ }
2820
+ )
2821
+ logElem.innerHTML += htmlLine + '<br>'
2822
+ logElem.scrollTop = logElem.scrollHeight
2758
2823
  }
2824
+
2759
2825
  stopBtn.onclick = function() {
2760
- if (ws) {
2761
- ws.close()
2762
- ws = null
2826
+ if (es) {
2827
+ es.close()
2828
+ es = null
2763
2829
  stopBtn.textContent = 'Start'
2764
- stopBtn.onclick = function() {
2765
- location.reload()
2766
- }
2830
+ stopBtn.onclick = function() { location.reload() }
2767
2831
  }
2768
2832
  }
2769
2833
  </script>
@@ -2807,29 +2871,18 @@ export class ScimGateway {
2807
2871
  await getHandlerServiceProviderConfig(ctx)
2808
2872
  return await onAfterHandle(ctx)
2809
2873
  case 'GET logger': // no onAfterHandle
2810
- if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') { // browser step 2, and other Bun ws(s) clients
2811
- logger.info(`${gwName}[${pluginName}] remote logger connected from ip address ${ctx.ip}`, { baseEntity: ctx?.routeObj?.baseEntity })
2812
- return server.upgrade(req, { // after upgrade, the server will handle the WebSocket connection configured in Bun.serve()
2813
- data: { // passed to WebSocket server Bun open handler
2814
- headers: req.headers,
2815
- url: req.url,
2816
- baseEntity: ctx?.routeObj?.baseEntity,
2817
- ip: ctx.ip,
2818
- nonce: this.Nonce.createItem(crypto.randomUUID()),
2819
- },
2820
- })
2821
- }
2822
- if (req.headers.has('sec-fetch-dest') && typeof Bun !== 'undefined') { // client is browser and not supporting WebSocket on Node.js
2823
- const url = new URL(ctx.origin)
2824
- const protocol = (url.protocol === 'https:' ? 'wss:' : 'ws:')
2825
- const js = wssInit.replace('{{protocol}}', protocol)
2826
- return new Response(js, { // browser step 1 => force WebSocket by sending javascript
2827
- status: 200,
2828
- headers: {
2829
- 'Content-Type': 'text/html; charset=utf-8',
2830
- },
2831
- })
2832
- } else return await getHandlerLoggerSSE(ctx) // using SSE for none WebSocket/wss e.g. curl -Ns http://localhost:8880/logger -u gwadmin:password | sed 's/\xE2\x80\x8B//g'
2874
+ if (req.headers.has('sec-fetch-dest')) { // client is browser
2875
+ if (ctx.request.headers.get('accept')?.includes('text/event-stream')) {
2876
+ return await getHandlerLoggerSSE(ctx)
2877
+ } else {
2878
+ return new Response(sseInit, {
2879
+ status: 200,
2880
+ headers: {
2881
+ 'Content-Type': 'text/html; charset=utf-8',
2882
+ },
2883
+ })
2884
+ }
2885
+ } else return await getHandlerLoggerSSE(ctx)
2833
2886
  case 'PATCH users':
2834
2887
  case 'PATCH groups':
2835
2888
  await patchHandler(ctx)
@@ -2884,49 +2937,9 @@ export class ScimGateway {
2884
2937
  const reqWithRaw = req as Request & { raw: IncomingMessage }
2885
2938
  return await route(reqWithRaw, srv.requestIP(req)?.address || '')
2886
2939
  },
2887
- websocket: {
2888
- open: (ws) => {
2889
- const data = ws.data as { headers: Headers, url: string, baseEntity: string, ip: string, nonce: string } || {}
2890
- let isAuthorized = false // client is already authenticated by initial http/https upgrade to websocket, anyhow passing data to be validated
2891
- if (data?.nonce && this.Nonce.isItemValid(data.nonce)) {
2892
- if (data.headers.has('authorization')) {
2893
- if (data.url.endsWith('/logger')) isAuthorized = true
2894
- }
2895
- }
2896
- if (!isAuthorized) {
2897
- logger.error(`${gwName}[${pluginName}] remote logger ip address ${data.ip} - WebSocket connection error: invalid nonce`, { baseEntity: data.baseEntity })
2898
- ws.close(3000, 'Unauthorized')
2899
- return
2900
- }
2901
-
2902
- const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
2903
- const sub = async (msgObj: Record<string, any>) => {
2904
- if (logger.levelToInt(msgObj.level) < levelInt) return
2905
- if (data.baseEntity !== 'undefined') { // if using baseEntity e.g. <host>/company1/logger, only include corresponding baseEntity logentries
2906
- if (data.baseEntity !== msgObj.baseEntity) return
2907
- }
2908
- ws.send(`${JSON.stringify(msgObj)}`)
2909
- }
2910
- logger.subscribe(sub)
2911
- ;(ws as any)._sub = sub
2912
- ;(ws as any)._baseEntity = data.baseEntity
2913
- ;(ws as any)._ip = data.ip
2914
- },
2915
- close: (ws) => {
2916
- const sub = (ws as any)._sub
2917
- const baseEntity = (ws as any)._baseEntity
2918
- const ip = (ws as any)._ip
2919
- if (sub) {
2920
- logger.unsubscribe(sub)
2921
- }
2922
- logger.info(`${gwName}[${pluginName}] remote logger disconnected from ip address ${ip}`, { baseEntity })
2923
- },
2924
- message: () => {},
2925
- },
2926
2940
  })
2927
2941
  } else {
2928
2942
  // using nodejs server either through Bun compability or Node.js
2929
-
2930
2943
  // get body from req
2931
2944
  async function getRequestBody(req: any): Promise<Buffer> {
2932
2945
  return new Promise((resolve, reject) => {
@@ -3581,7 +3594,7 @@ Content-Transfer-Encoding: quoted-printable
3581
3594
  if (lastKey === 'password' && key.startsWith('scimgateway.auth.basic')) foundBasic = true
3582
3595
  else if (lastKey === 'token' && key.startsWith('scimgateway.auth.bearerToken')) foundBearerToken = true
3583
3596
  else if (lastKey === 'tenantIdGUID' && key.startsWith('scimgateway.auth.bearerJwtAzure')) foundBearerJwtAzure = true
3584
- else if (lastKey === 'secret' && key.startsWith('scimgateway.auth.bearerJwt')) foundBearerJwt = true
3597
+ else if ((lastKey === 'publicKey' || lastKey === 'secret' || lastKey === 'wellKnownUri') && key.startsWith('scimgateway.auth.bearerJwt')) foundBearerJwt = true
3585
3598
  else if (lastKey === 'clientSecret' && key.startsWith('scimgateway.auth.bearerOAuth')) foundBearerOAuth = true
3586
3599
 
3587
3600
  // certificate full path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.4.2",
3
+ "version": "5.4.4",
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)",
@@ -48,6 +48,7 @@
48
48
  "hyco-https": "^1.4.5",
49
49
  "is-in-subnet": "^4.0.1",
50
50
  "jsonwebtoken": "^9.0.2",
51
+ "jwk-to-pem": "^2.0.7",
51
52
  "ldapjs": "^3.0.7",
52
53
  "lokijs": "^1.5.12",
53
54
  "mongodb": "^6.16.0",
@@ -65,6 +66,7 @@
65
66
  "@types/bun": "latest",
66
67
  "@types/dot-object": "^2.1.6",
67
68
  "@types/jsonwebtoken": "^9.0.9",
69
+ "@types/jwk-to-pem": "^2.0.3",
68
70
  "@types/node": "latest",
69
71
  "@types/nodemailer": "^6.4.17",
70
72
  "@types/passport": "^1.0.17",