scimgateway 5.3.8 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,10 +19,7 @@ Latest news:
19
19
  - [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
20
  - [ETag](https://datatracker.ietf.org/doc/html/rfc7644#section-3.14) is now supported
21
21
  - [Bulk Operations](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) is now supported
22
- - Remote real-time log subscription for monitoring and centralized logging
23
- using browser and url: `https://<host>/logger`
24
- `curl -N https://<host>/logger -u user:password`
25
- custom client API, see configuration notes
22
+ - Remote real-time log subscription for centralized logging and monitoring. Using browser `https://<host>/logger`, curl or custom client API - see configuration notes
26
23
  - By configuring the chainingBaseUrl, it is now possible to chain multiple gateways in sequence, such as `gateway1->gateway2->gateway3->endpoint`. In this setup, gateway beave much like a reverse proxy, validating authorization at each step unless PassThrough mode is enabled. Chaining is also supported in stream subscriber mode
27
24
  - Email, onError and sendMail() supports more secure RESTful OAuth for Microsoft Exchange Online (ExO) and Google Workspace Gmail, alongside traditional SMTP Auth for all mail systems. HelperRest supports a wide range of common authentication methods, including basicAuth, bearerAuth, tokenAuth, oauth, oauthSamlBearer, oauthJwtBearer and Auth PassTrough
28
25
  - Major version **v5.0.0** marks a shift from JavaScript to native TypeScript and prioritizes [Bun](https://bun.sh/) over Node.js. This upgrade requires some modifications to existing plugins.
@@ -139,7 +136,7 @@ If internet connection is blocked, we could install on another machine and copy
139
136
  http://localhost:8880/Groups
140
137
  => Logon using gwadmin/password and two users and groups should be listed
141
138
 
142
- Start a new browser for log monitoring (might not be supported by Safari)
139
+ Start a new browser for remote log monitoring
143
140
  using url: http://localhost:8880/logger
144
141
 
145
142
  http://localhost:8880/Users/bjensen
@@ -397,7 +394,7 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
397
394
 
398
395
  - **log.loglevel.console** - off, debug, info, warn or error. Default off. Output to stdout and errors to stderr
399
396
 
400
- - **log.loglevel.push** - off, debug, info, warn or error. Default info. Push to stream that can be used by client subscriber
397
+ - **log.loglevel.push** - off, debug, info, warn or error. Default info. Push to stream used by remote real-time log subscription
401
398
 
402
399
  - **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.
403
400
 
@@ -750,9 +747,11 @@ Please see code editor method HelperRest doRequest() IntelliSense for type and o
750
747
  Using remote real-time log subscription we may implement custom logic like monitoring and centralized logging
751
748
 
752
749
  - using browser and url: https://host/logger
753
- - curl -N https://host/logger -u gwread:password
754
- - curl -N https://host/logger -H "Authorization: Bearer secret"
755
- - custom client API
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)
753
+ - custom client API (see example below)
754
+ - not supported by Azure Relay
756
755
 
757
756
  We may configure read-only user/secret for log collection purpose
758
757
 
@@ -781,8 +780,8 @@ We may configure read-only user/secret for log collection purpose
781
780
  ...
782
781
  }
783
782
 
784
- push logger using default `info` log level
785
- push log level may be customized by configuration
783
+ Remote log subscription is configured by log.loglevel.push and the push logger has default loglevel set to `info`
784
+ Example using debug loglevel:
786
785
 
787
786
  "log": {
788
787
  "loglevel": {
@@ -790,57 +789,71 @@ push log level may be customized by configuration
790
789
  }
791
790
  }
792
791
 
793
- Example code implementing subscriber for real-time log messages collection
792
+ Example code implementing remote real-time log subscription and custom message handling
794
793
 
795
- let headers = new Headers()
796
- headers.append('Authorization', 'Basic ' + btoa('gwadmin' + ':' + 'password'))
797
-
798
- // message handling and custom logic
799
- // we could also do JSON.parse(message) and granular filtering on log "level"
800
- const messageHandler = async (message: string) => {
801
- console.log(message)
802
- }
803
-
804
- let ignoreCatch = false
805
- do { // retry loop when connection closed or service unavailable
806
- if (ignoreCatch) ignoreCatch = false
807
-
808
- try {
809
- const resp = await fetch("http://localhost:8880/logger", {
810
- method: "GET",
811
- headers: headers,
812
- })
813
-
814
- const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader()
815
- console.log('Now awaiting log events...\n')
816
-
817
- while (true) {
818
- const { value, done } = await reader.read()
819
- if (done) break
820
- if (value.at(-1) !== '\n') continue
821
- const message = value.slice(0, -1)
822
- messageHandler(message)
823
- }
824
-
825
- // shouldn't be here... authentication failure?
826
- const e = {
827
- url: resp.url,
828
- status: resp.status,
829
- statusText: resp.statusText
830
- }
831
- console.error('error', e)
832
-
833
- } catch (err: any) {
834
- if (['ConnectionClosed', 'ConnectionRefused', 'ECONNRESET'].includes(err.code)) {
835
- console.log('Connection closed or service unavailable')
836
- ignoreCatch = true
837
- await Bun.sleep(10 * 1000)
838
- } else console.error(err)
839
- }
840
-
841
- } while (ignoreCatch)
842
-
843
- console.log('\n\ndone!')
794
+ ```
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"
808
+ const messageHandler = async (message: string) => {
809
+ console.log(message)
810
+ }
811
+
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 => {
828
+ 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
+
849
+ const retry = async () => {
850
+ console.log('🔁 Retry in 10 seconds...')
851
+ await Bun.sleep(10 * 1000)
852
+ startWebSocket()
853
+ }
854
+
855
+ startWebSocket()
856
+ ```
844
857
 
845
858
  ### Configuration notes - Azure Relay
846
859
 
@@ -1460,7 +1473,11 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1460
1473
 
1461
1474
  ## Change log
1462
1475
 
1463
- ## Change log
1476
+ ### v5.4.0
1477
+
1478
+ [Improved]
1479
+
1480
+ - Remote real-time log subscription now prioritize using WebSocket over SSE. Using browser will show loglevel colors. If running Node.js, WebSocket is not supported and SSE will be used. Remote logger is not supported by Azure Relay.
1464
1481
 
1465
1482
  ### v5.3.8
1466
1483
 
@@ -26,8 +26,6 @@ import * as utils from './utils.ts'
26
26
  import * as utilsScim from './utils-scim.ts'
27
27
  import * as stream from './scim-stream.js'
28
28
  export * from './helper-rest.ts'
29
- // @ts-expect-error: has no declaration
30
- import * as hycoPkg from 'hyco-https'
31
29
 
32
30
  export class ScimGateway {
33
31
  private config: any
@@ -39,6 +37,7 @@ export class ScimGateway {
39
37
  private getMemberOf: any
40
38
  private getAppRoles: any
41
39
  private pub: any
40
+ private Nonce = new utils.TimerMapCache(2000)
42
41
  // @ts-expect-error: has no initializer
43
42
  private helperRest: HelperRest
44
43
  /** pluginName is the name of plugin e.g., plugin-loki */
@@ -566,7 +565,7 @@ export class ScimGateway {
566
565
  }
567
566
 
568
567
  const logResult = async (ctx: Context) => {
569
- if (ctx.path === '/ping' || ctx.path === '/favicon.ico') return
568
+ if (ctx.path === '/ping' || ctx.path === '/favicon.ico' || ctx.path.startsWith('/apple-touch-icon')) return
570
569
  const ellapsed = performance.now() - ctx.perfStart
571
570
  let userName
572
571
  const [authType, authToken] = (ctx.request.headers.get('authorization') || '').split(' ') // [0] = 'Basic' or 'Bearer'
@@ -774,7 +773,7 @@ export class ScimGateway {
774
773
 
775
774
  // end auth methods - used by auth
776
775
 
777
- const isAuthorized = async (ctx: Context): Promise<boolean> => { // authentication/authorization
776
+ const isAuthorized = async (ctx: Context): Promise<boolean> => { // authentication/authorization
778
777
  const [authType, authToken] = (ctx.request.headers.get('authorization') || '').split(' ') // [0] = 'Basic' or 'Bearer'
779
778
  try { // authenticate
780
779
  const arrResolve = await Promise.all([
@@ -799,7 +798,7 @@ export class ScimGateway {
799
798
  }
800
799
  if (authType === 'Bearer') ctx.response.headers.set('WWW-Authenticate', 'Bearer realm=""')
801
800
  else if (found.Basic) ctx.response.headers.set('WWW-Authenticate', 'Basic realm=""')
802
- if (ctx.request.url !== '/favicon.ico') logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
801
+ if (ctx.request.url !== '/favicon.ico' && !ctx.request.url.startsWith('/apple-touch-icon')) logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${err.message}`)
803
802
  return false
804
803
  } catch (err: any) {
805
804
  if (authType === 'Bearer') {
@@ -877,7 +876,7 @@ export class ScimGateway {
877
876
  funcHandler.getHandlerServiceProviderConfig = getHandlerServiceProviderConfig
878
877
 
879
878
  // getHandlerLogger implements SSE based online publisher for log events
880
- const getHandlerLogger = async (ctx: Context) => {
879
+ const getHandlerLoggerSSE = async (ctx: Context) => {
881
880
  const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
882
881
  const encoder = new TextEncoder()
883
882
 
@@ -900,6 +899,7 @@ export class ScimGateway {
900
899
  clearInterval(keepAliveInterval)
901
900
  logger.unsubscribe(sub)
902
901
  controller.close()
902
+ logger.info(`${gwName}[${pluginName}] remote logger disconnected from ip address ${ctx.ip}`)
903
903
  }
904
904
 
905
905
  ctx.request.signal.onabort = cleanup // Bun
@@ -1108,7 +1108,6 @@ export class ScimGateway {
1108
1108
  } else if (eTagIfNoneMatch && (eTagIfNoneMatch.includes(eTag) || eTagIfNoneMatch.includes('*'))) {
1109
1109
  ctx.response.headers.set('ETag', eTag)
1110
1110
  ctx.response.status = 304 // Not Modified
1111
- ctx.response.body = ''
1112
1111
  return
1113
1112
  }
1114
1113
  }
@@ -1623,7 +1622,6 @@ export class ScimGateway {
1623
1622
  } else if (eTagIfNoneMatch && (eTagIfNoneMatch.includes(eTag) || eTagIfNoneMatch.includes('*'))) {
1624
1623
  ctx.response.headers.set('ETag', eTag)
1625
1624
  ctx.response.status = 412 // Precondition Failed
1626
- ctx.response.body = ''
1627
1625
  return
1628
1626
  }
1629
1627
  }
@@ -1901,7 +1899,6 @@ export class ScimGateway {
1901
1899
  ctx.request.headers.delete('if-none-match')
1902
1900
  await getHandlerId(ctx) // ctx.response.body now updated with userObject to be returned
1903
1901
  if (ctx.response.status && ctx.response.status !== 200) { // clear any get error
1904
- ctx.response.body = undefined
1905
1902
  ctx.response.status = 204
1906
1903
  }
1907
1904
  } catch (err: any) {
@@ -2534,6 +2531,7 @@ export class ScimGateway {
2534
2531
  if (ctx.path === '/ping') {
2535
2532
  ctx.response.status = 200
2536
2533
  ctx.response.body = 'hello'
2534
+ ctx.response.headers.set('content-type', 'text/plain')
2537
2535
  return ctx
2538
2536
  }
2539
2537
  if (ctx.path === '/_ah/start' || ctx.path === '/_ah/stop') {
@@ -2590,6 +2588,7 @@ export class ScimGateway {
2590
2588
  ctx.response.body = JSON.stringify(result.body)
2591
2589
  } catch (err) {
2592
2590
  ctx.response.body = result.body
2591
+ ctx.response.headers.set('content-type', 'text/plain')
2593
2592
  }
2594
2593
  } catch (err: any) {
2595
2594
  try {
@@ -2622,24 +2621,34 @@ export class ScimGateway {
2622
2621
  if (!ctx.response.status) ctx.response.status = 200
2623
2622
  switch (ctx.response.status) {
2624
2623
  case 401:
2625
- if (!ctx.response.body) ctx.response.body = 'Unauthorized'
2624
+ if (!ctx.response.body) {
2625
+ ctx.response.body = 'Unauthorized'
2626
+ ctx.response.headers.set('content-type', 'text/plain')
2627
+ }
2626
2628
  break
2627
2629
  case 403:
2628
- if (!ctx.response.body) ctx.response.body = 'Forbidden'
2630
+ if (!ctx.response.body) {
2631
+ ctx.response.body = 'Forbidden'
2632
+ ctx.response.headers.set('content-type', 'text/plain')
2633
+ }
2629
2634
  break
2630
2635
  case 404:
2631
- if (!ctx.response.body) ctx.response.body = 'NOT_FOUND'
2636
+ if (!ctx.response.body) {
2637
+ ctx.response.body = 'NOT_FOUND'
2638
+ ctx.response.headers.set('content-type', 'text/plain')
2639
+ }
2632
2640
  break
2633
2641
  case 500:
2634
- if (!ctx.response.body) ctx.response.body = 'Internal Server Error'
2642
+ if (!ctx.response.body) {
2643
+ ctx.response.body = 'Internal Server Error'
2644
+ ctx.response.headers.set('content-type', 'text/plain')
2645
+ }
2635
2646
  break
2636
2647
  }
2637
- const body = ctx.response.body
2638
- if (body) {
2639
- try {
2640
- JSON.parse(body)
2641
- ctx.response.headers.set('content-type', 'application/scim+json; charset=utf-8')
2642
- } catch (err) { void 0 }
2648
+ let body = ctx.response.body
2649
+ if (body === '') body = undefined
2650
+ if (body && !ctx.response.headers.has('content-type')) {
2651
+ ctx.response.headers.set('content-type', 'application/scim+json; charset=utf-8')
2643
2652
  }
2644
2653
  const response = new Response(body, { status: ctx.response.status, headers: ctx.response.headers })
2645
2654
  logResult(ctx)
@@ -2693,7 +2702,42 @@ export class ScimGateway {
2693
2702
  const isPublisherEnabled = this.config.scimgateway.stream.publisher.enabled
2694
2703
  const isChainingEnabled = this.config.scimgateway.chainingBaseUrl
2695
2704
 
2696
- async function route(req: Request & { raw: IncomingMessage }, ip: string): Promise<Response> {
2705
+ const wssInit = `
2706
+ <!DOCTYPE html>
2707
+ <html>
2708
+ <body>
2709
+ <h3>SCIM Gateway remote logger</h3>
2710
+ <pre id="log"></pre>
2711
+ <script>
2712
+ const logElem = document.getElementById('log')
2713
+ const ws = new WebSocket('{{protocol}}//' + location.host + '/logger')
2714
+ ws.onmessage = function(event) {
2715
+ event.data.split('\\n').forEach(function(line) {
2716
+ if (!line.trim()) return
2717
+ // Highlight only the log level
2718
+ var htmlLine = line.replace(
2719
+ /(level":"\\s*)(debug|info|warn|error)/i,
2720
+ function(match, p1, p2) {
2721
+ var color = ''
2722
+ switch (p2.toLowerCase()) {
2723
+ case 'debug': color = '#888'; break
2724
+ case 'info': color = 'blue'; break
2725
+ case 'warn': color = 'orange'; break
2726
+ case 'error': color = 'red'; break
2727
+ default: color = 'black'
2728
+ }
2729
+ return p1 + '<span style="color:' + color + ';font-weight:bold">' + p2 + '</span>'
2730
+ }
2731
+ );
2732
+ logElem.innerHTML += htmlLine + '<br>'
2733
+ })
2734
+ }
2735
+ </script>
2736
+ </body>
2737
+ </html>
2738
+ `
2739
+
2740
+ const route = async (req: Request & { raw: IncomingMessage }, ip: string): Promise<Response> => {
2697
2741
  const ctx = await onBeforeHandle(req, ip)
2698
2742
  if (ctx.response.status) { // 401/Unauthorized - 404/NOT_FOUND
2699
2743
  return await onAfterHandle(ctx)
@@ -2728,8 +2772,29 @@ export class ScimGateway {
2728
2772
  case 'GET serviceproviderconfigs':
2729
2773
  await getHandlerServiceProviderConfig(ctx)
2730
2774
  return await onAfterHandle(ctx)
2731
- case 'GET logger':
2732
- return await getHandlerLogger(ctx) // no onAfterHandle
2775
+ case 'GET logger': // no onAfterHandle
2776
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') { // browser step 2, and other Bun ws(s) clients
2777
+ logger.info(`${gwName}[${pluginName}] remote logger connected from ip address ${ctx.ip}`)
2778
+ return server.upgrade(req, { // after upgrade, the server will handle the WebSocket connection configured in Bun.serve()
2779
+ data: { // passed to WebSocket server Bun open handler
2780
+ headers: req.headers,
2781
+ url: req.url,
2782
+ ip: ctx.ip,
2783
+ nonce: this.Nonce.createItem(crypto.randomUUID()),
2784
+ },
2785
+ })
2786
+ }
2787
+ if (req.headers.has('sec-fetch-dest') && typeof Bun !== 'undefined') { // client is browser and not supporting WebSocket on Node.js
2788
+ const url = new URL(ctx.origin)
2789
+ const protocol = (url.protocol === 'https:' ? 'wss:' : 'ws:')
2790
+ const js = wssInit.replace('{{protocol}}', protocol)
2791
+ return new Response(js, { // browser step 1 => force WebSocket by sending javascript
2792
+ status: 200,
2793
+ headers: {
2794
+ 'Content-Type': 'text/html; charset=utf-8',
2795
+ },
2796
+ })
2797
+ } 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'
2733
2798
  case 'PATCH users':
2734
2799
  case 'PATCH groups':
2735
2800
  await patchHandler(ctx)
@@ -2779,14 +2844,44 @@ export class ScimGateway {
2779
2844
  idleTimeout,
2780
2845
  hostname, // hostname === 'localhost' ? hostname : undefined, // bun defaults to '0.0.0.0', but using '0.0.0.0.' or other ip like '127.0.0.1' becomes extremly slow - bun bug
2781
2846
  tls,
2782
- async fetch(req, srv) {
2783
- // start route processing and return response
2847
+ fetch: async (req, srv) => {
2848
+ // start route handlers
2784
2849
  const reqWithRaw = req as Request & { raw: IncomingMessage }
2785
2850
  return await route(reqWithRaw, srv.requestIP(req)?.address || '')
2786
2851
  },
2787
- error(err) {
2788
- logger.error(`${gwName} internal error: ${err.message}`)
2789
- return new Response('Internal Server Error', { status: 500 })
2852
+ websocket: {
2853
+ open: (ws) => {
2854
+ const data = ws.data as { headers: Headers, url: string, ip: string, nonce: string } || {}
2855
+ let isAuthorized = false // client is already authenticated by initial http/https upgrade to websocket, anyhow passing data to be validated
2856
+ if (data?.nonce && this.Nonce.isItemValid(data.nonce)) {
2857
+ if (data.headers.has('authorization')) {
2858
+ if (data.url.endsWith('/logger')) isAuthorized = true
2859
+ }
2860
+ }
2861
+ if (!isAuthorized) {
2862
+ logger.error(`${gwName}[${pluginName}] remote logger ip address ${data.ip} - WebSocket connection error: invalid nonce`)
2863
+ ws.close(3000, 'Unauthorized')
2864
+ return
2865
+ }
2866
+
2867
+ const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
2868
+ const sub = async (msgObj: Record<string, any>) => {
2869
+ if (logger.levelToInt(msgObj.level) < levelInt) return
2870
+ ws.send(`${JSON.stringify(msgObj)}`)
2871
+ }
2872
+ logger.subscribe(sub)
2873
+ ;(ws as any)._sub = sub
2874
+ ;(ws as any)._ip = data.ip
2875
+ },
2876
+ close: (ws) => {
2877
+ const sub = (ws as any)._sub
2878
+ const ip = (ws as any)._ip
2879
+ if (sub) {
2880
+ logger.unsubscribe(sub)
2881
+ }
2882
+ logger.info(`${gwName}[${pluginName}] remote logger disconnected from ip address ${ip}`)
2883
+ },
2884
+ message: () => {},
2790
2885
  },
2791
2886
  })
2792
2887
  } else {
@@ -2871,6 +2966,8 @@ export class ScimGateway {
2871
2966
  const bodyText = await streamToString(response.body)
2872
2967
  res.end(bodyText)
2873
2968
  }
2969
+ } else {
2970
+ res.end()
2874
2971
  }
2875
2972
  } catch (err: any) {
2876
2973
  logger.error(`${gwName} internal error: ${err.message}`)
@@ -2882,48 +2979,53 @@ export class ScimGateway {
2882
2979
  // create nodejs server and start listen
2883
2980
  if (this.config.scimgateway.azureRelay?.enabled === true) {
2884
2981
  // Azure Relay listener server
2885
- let url: URL = {} as URL
2886
- try {
2887
- url = new URL(this.config.scimgateway.azureRelay.connectionUrl) // Azure Relay hybrid connection URL: 'https://<namespace>.servicebus.windows.net/<hybrid-connection-name>'
2888
- } catch (err: any) {
2889
- logger.error(`${gwName}[${pluginName}] Azure Relay configuration scimgateway.azureRelay.connectionUrl - error: ${err.message}`)
2890
- }
2891
- const hyco = hycoPkg.default || hycoPkg
2892
- const ns = url.hostname// <namespace>.servicebus.windows.net
2893
- const path = url?.pathname?.replace(/^[\s\/]+|[\s\/]+$/g, '') // <hybrid-connection-name> - removing any leading/trailing whitespace and '/'
2894
- const keyrule = this.config.scimgateway.azureRelay.keyRule || 'RootManageSharedAccessKey'
2895
- const key = this.config.scimgateway.azureRelay.apiKey || '' // Azure Relay - SAS Primary Key
2896
- const uri = hyco.createRelayListenUri(ns, path) // wss://<namespace>.servicebus.windows.net:443/$hc/<hybrid-connection-name>?sb-hc-action=listen
2897
-
2898
- server = hyco.createRelayedServer(
2899
- {
2900
- server: uri,
2901
- token: () => hyco.createRelayToken(uri, keyrule, key),
2902
- },
2903
- async (req: IncomingMessage, res: ServerResponse) => {
2904
- doFetchApi(req, res)
2905
- })
2906
- server.listen()
2907
-
2908
- { // check if Azure Relay listener is working by sending a 5 sec delayed ping request
2909
- let options = {
2910
- connection: {
2911
- options: {
2912
- headers: {
2913
- ServiceBusAuthorization: hyco.createRelayToken(uri, keyrule, key),
2982
+ (async () => {
2983
+ // @ts-expect-error: has no declaration
2984
+ const hycoPkg = await import('hyco-https')
2985
+ const hyco = hycoPkg.default || hycoPkg
2986
+ let url: URL = {} as URL
2987
+ try {
2988
+ url = new URL(this.config.scimgateway.azureRelay.connectionUrl) // Azure Relay hybrid connection URL: 'https://<namespace>.servicebus.windows.net/<hybrid-connection-name>'
2989
+ } catch (err: any) {
2990
+ logger.error(`${gwName}[${pluginName}] Azure Relay configuration scimgateway.azureRelay.connectionUrl - error: ${err.message}`)
2991
+ }
2992
+
2993
+ const ns = url.hostname// <namespace>.servicebus.windows.net
2994
+ const path = url?.pathname?.replace(/^[\s\/]+|[\s\/]+$/g, '') // <hybrid-connection-name> - removing any leading/trailing whitespace and '/'
2995
+ const keyrule = this.config.scimgateway.azureRelay.keyRule || 'RootManageSharedAccessKey'
2996
+ const key = this.config.scimgateway.azureRelay.apiKey || '' // Azure Relay - SAS Primary Key
2997
+ const uri = hyco.createRelayListenUri(ns, path) // wss://<namespace>.servicebus.windows.net:443/$hc/<hybrid-connection-name>?sb-hc-action=listen
2998
+
2999
+ server = hyco.createRelayedServer(
3000
+ {
3001
+ server: uri,
3002
+ token: () => hyco.createRelayToken(uri, keyrule, key),
3003
+ },
3004
+ async (req: IncomingMessage, res: ServerResponse) => {
3005
+ doFetchApi(req, res)
3006
+ })
3007
+ server.listen()
3008
+
3009
+ { // check if Azure Relay listener is working by sending a 5 sec delayed ping request
3010
+ let options = {
3011
+ connection: {
3012
+ options: {
3013
+ headers: {
3014
+ ServiceBusAuthorization: hyco.createRelayToken(uri, keyrule, key),
3015
+ },
2914
3016
  },
2915
3017
  },
2916
- },
2917
- }
2918
- setTimeout(async () => {
2919
- try {
2920
- if (!this.helperRest) this.helperRest = this.newHelperRest()
2921
- await this.helperRest.doRequest('undefined', 'GET', `${this.config.scimgateway.azureRelay.connectionUrl}/ping`, null, null, options)
2922
- } catch (err: any) {
2923
- logger.error(`${gwName}[${pluginName}] Azure Relay listener failed to start - ping test doRequest() returned an error - please verify configuration scimgateway.azureRelay.connectionUrl/apiKey including the Azure Relay setup}`)
2924
3018
  }
2925
- }, 5 * 1000)
2926
- }
3019
+ setTimeout(async () => {
3020
+ try {
3021
+ if (!this.helperRest) this.helperRest = this.newHelperRest()
3022
+ await this.helperRest.doRequest('undefined', 'GET', `${this.config.scimgateway.azureRelay.connectionUrl}/ping`, null, null, options)
3023
+ } catch (err: any) {
3024
+ logger.error(`${gwName}[${pluginName}] Azure Relay listener failed to start - ping test doRequest() returned an error - please verify configuration scimgateway.azureRelay.connectionUrl/apiKey including the Azure Relay setup}`)
3025
+ }
3026
+ }, 5 * 1000)
3027
+ }
3028
+ })()
2927
3029
  } else {
2928
3030
  // nodejs server
2929
3031
  if (tls.key) {
package/lib/utils.ts CHANGED
@@ -710,3 +710,49 @@ export const getEtag = function (obj: Record<string, any>): string {
710
710
  }
711
711
  return eTag
712
712
  }
713
+
714
+ /**
715
+ * TimerMapCache caches items for specified time and also clear items on valdation
716
+ * @param obj full object to calculate ETag from
717
+ * @returns ETag string as W/"<hash>"
718
+ */
719
+ export class TimerMapCache {
720
+ private itemCache = new Map<string, NodeJS.Timeout>()
721
+ private itemTimeout: number
722
+
723
+ constructor(itemTimeout: number = 5 * 1000) { // default 5 seconds in cache
724
+ this.itemCache = new Map<string, NodeJS.Timeout>()
725
+ this.itemTimeout = itemTimeout
726
+ }
727
+
728
+ /**
729
+ * createItem creates an item in cache that will be cleared when using isItemValid() or after timeout set by class initialization
730
+ * @param item
731
+ * @returns item
732
+ */
733
+ createItem(item: string): string {
734
+ if (!item) return ''
735
+ this.itemCache.set(item, setTimeout(() => {
736
+ console.log('deleting item')
737
+ this.itemCache.delete(item)
738
+ },
739
+ this.itemTimeout))
740
+ return item
741
+ }
742
+
743
+ /**
744
+ * isItemValid checks if item can be found in cache. If found, it will be removed from cache and return true
745
+ * @param item
746
+ * @returns true if valid, else false
747
+ */
748
+ isItemValid(item: string): boolean {
749
+ if (!item) return false
750
+ const timeout = this.itemCache.get(item)
751
+ const res = !!timeout
752
+ if (res) {
753
+ clearTimeout(timeout)
754
+ this.itemCache.delete(item)
755
+ }
756
+ return res
757
+ }
758
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.3.8",
3
+ "version": "5.4.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)",