scimgateway 5.3.8 → 5.4.1
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 +85 -62
- package/lib/scimgateway.ts +199 -66
- package/lib/utils.ts +46 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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 -
|
|
754
|
-
- curl -
|
|
755
|
-
-
|
|
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
|
|
785
|
-
|
|
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
|
|
792
|
+
Example code implementing remote real-time log subscription and custom message handling
|
|
794
793
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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,17 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1460
1473
|
|
|
1461
1474
|
## Change log
|
|
1462
1475
|
|
|
1463
|
-
|
|
1476
|
+
### v5.4.1
|
|
1477
|
+
|
|
1478
|
+
[Improved]
|
|
1479
|
+
|
|
1480
|
+
- Remote real-time logger, stop/start button added when using browser
|
|
1481
|
+
|
|
1482
|
+
### v5.4.0
|
|
1483
|
+
|
|
1484
|
+
[Improved]
|
|
1485
|
+
|
|
1486
|
+
- Some underlying enhancements have been made to the remote real-time logger. When using a browser, log level colors are now shown. Note: the remote logger is not supported via Azure Relay
|
|
1464
1487
|
|
|
1465
1488
|
### v5.3.8
|
|
1466
1489
|
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
-
|
|
2638
|
-
if (body)
|
|
2639
|
-
|
|
2640
|
-
|
|
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,73 @@ export class ScimGateway {
|
|
|
2693
2702
|
const isPublisherEnabled = this.config.scimgateway.stream.publisher.enabled
|
|
2694
2703
|
const isChainingEnabled = this.config.scimgateway.chainingBaseUrl
|
|
2695
2704
|
|
|
2696
|
-
|
|
2705
|
+
const wssInit = `
|
|
2706
|
+
<!DOCTYPE html>
|
|
2707
|
+
<html>
|
|
2708
|
+
<head>
|
|
2709
|
+
<style>
|
|
2710
|
+
.header-flex {
|
|
2711
|
+
display: flex;
|
|
2712
|
+
align-items: center;
|
|
2713
|
+
gap: 16px;
|
|
2714
|
+
}
|
|
2715
|
+
#stopBtn {
|
|
2716
|
+
padding: 4px 18px;
|
|
2717
|
+
font-size: 12px;
|
|
2718
|
+
background: #eee;
|
|
2719
|
+
border: 1px solid #888;
|
|
2720
|
+
border-radius: 4px;
|
|
2721
|
+
color: #222;
|
|
2722
|
+
cursor: pointer;
|
|
2723
|
+
}
|
|
2724
|
+
</style>
|
|
2725
|
+
</head>
|
|
2726
|
+
<body>
|
|
2727
|
+
<div class="header-flex">
|
|
2728
|
+
<h3 style="margin:0;">SCIM Gateway remote logger</h3>
|
|
2729
|
+
<button id="stopBtn" type="button">Stop</button>
|
|
2730
|
+
</div>
|
|
2731
|
+
<pre id="log"></pre>
|
|
2732
|
+
<script>
|
|
2733
|
+
const stopBtn = document.getElementById('stopBtn')
|
|
2734
|
+
const logElem = document.getElementById('log')
|
|
2735
|
+
let ws = new WebSocket('{{protocol}}//' + location.host + '/logger')
|
|
2736
|
+
ws.onmessage = function(event) {
|
|
2737
|
+
event.data.split('\\n').forEach(function(line) {
|
|
2738
|
+
if (!line.trim()) return
|
|
2739
|
+
const htmlLine = line.replace(
|
|
2740
|
+
/(level":"\\s*)(debug|info|warn|error)/i,
|
|
2741
|
+
function(match, p1, p2) {
|
|
2742
|
+
let color = ''
|
|
2743
|
+
switch (p2.toLowerCase()) {
|
|
2744
|
+
case 'debug': color = '#888'; break
|
|
2745
|
+
case 'info': color = 'blue'; break
|
|
2746
|
+
case 'warn': color = 'orange'; break
|
|
2747
|
+
case 'error': color = 'red'; break
|
|
2748
|
+
default: color = 'black'
|
|
2749
|
+
}
|
|
2750
|
+
return p1 + '<span style="color:' + color + ';font-weight:bold">' + p2 + '</span>'
|
|
2751
|
+
}
|
|
2752
|
+
);
|
|
2753
|
+
logElem.innerHTML += htmlLine + '<br>'
|
|
2754
|
+
})
|
|
2755
|
+
}
|
|
2756
|
+
stopBtn.onclick = function() {
|
|
2757
|
+
if (ws) {
|
|
2758
|
+
ws.close()
|
|
2759
|
+
ws = null
|
|
2760
|
+
stopBtn.textContent = 'Start'
|
|
2761
|
+
stopBtn.onclick = function() {
|
|
2762
|
+
location.reload()
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
</script>
|
|
2767
|
+
</body>
|
|
2768
|
+
</html>
|
|
2769
|
+
`
|
|
2770
|
+
|
|
2771
|
+
const route = async (req: Request & { raw: IncomingMessage }, ip: string): Promise<Response> => {
|
|
2697
2772
|
const ctx = await onBeforeHandle(req, ip)
|
|
2698
2773
|
if (ctx.response.status) { // 401/Unauthorized - 404/NOT_FOUND
|
|
2699
2774
|
return await onAfterHandle(ctx)
|
|
@@ -2728,8 +2803,29 @@ export class ScimGateway {
|
|
|
2728
2803
|
case 'GET serviceproviderconfigs':
|
|
2729
2804
|
await getHandlerServiceProviderConfig(ctx)
|
|
2730
2805
|
return await onAfterHandle(ctx)
|
|
2731
|
-
case 'GET logger':
|
|
2732
|
-
|
|
2806
|
+
case 'GET logger': // no onAfterHandle
|
|
2807
|
+
if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') { // browser step 2, and other Bun ws(s) clients
|
|
2808
|
+
logger.info(`${gwName}[${pluginName}] remote logger connected from ip address ${ctx.ip}`)
|
|
2809
|
+
return server.upgrade(req, { // after upgrade, the server will handle the WebSocket connection configured in Bun.serve()
|
|
2810
|
+
data: { // passed to WebSocket server Bun open handler
|
|
2811
|
+
headers: req.headers,
|
|
2812
|
+
url: req.url,
|
|
2813
|
+
ip: ctx.ip,
|
|
2814
|
+
nonce: this.Nonce.createItem(crypto.randomUUID()),
|
|
2815
|
+
},
|
|
2816
|
+
})
|
|
2817
|
+
}
|
|
2818
|
+
if (req.headers.has('sec-fetch-dest') && typeof Bun !== 'undefined') { // client is browser and not supporting WebSocket on Node.js
|
|
2819
|
+
const url = new URL(ctx.origin)
|
|
2820
|
+
const protocol = (url.protocol === 'https:' ? 'wss:' : 'ws:')
|
|
2821
|
+
const js = wssInit.replace('{{protocol}}', protocol)
|
|
2822
|
+
return new Response(js, { // browser step 1 => force WebSocket by sending javascript
|
|
2823
|
+
status: 200,
|
|
2824
|
+
headers: {
|
|
2825
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
2826
|
+
},
|
|
2827
|
+
})
|
|
2828
|
+
} 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
2829
|
case 'PATCH users':
|
|
2734
2830
|
case 'PATCH groups':
|
|
2735
2831
|
await patchHandler(ctx)
|
|
@@ -2779,14 +2875,44 @@ export class ScimGateway {
|
|
|
2779
2875
|
idleTimeout,
|
|
2780
2876
|
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
2877
|
tls,
|
|
2782
|
-
async
|
|
2783
|
-
// start route
|
|
2878
|
+
fetch: async (req, srv) => {
|
|
2879
|
+
// start route handlers
|
|
2784
2880
|
const reqWithRaw = req as Request & { raw: IncomingMessage }
|
|
2785
2881
|
return await route(reqWithRaw, srv.requestIP(req)?.address || '')
|
|
2786
2882
|
},
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2883
|
+
websocket: {
|
|
2884
|
+
open: (ws) => {
|
|
2885
|
+
const data = ws.data as { headers: Headers, url: string, ip: string, nonce: string } || {}
|
|
2886
|
+
let isAuthorized = false // client is already authenticated by initial http/https upgrade to websocket, anyhow passing data to be validated
|
|
2887
|
+
if (data?.nonce && this.Nonce.isItemValid(data.nonce)) {
|
|
2888
|
+
if (data.headers.has('authorization')) {
|
|
2889
|
+
if (data.url.endsWith('/logger')) isAuthorized = true
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (!isAuthorized) {
|
|
2893
|
+
logger.error(`${gwName}[${pluginName}] remote logger ip address ${data.ip} - WebSocket connection error: invalid nonce`)
|
|
2894
|
+
ws.close(3000, 'Unauthorized')
|
|
2895
|
+
return
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
const levelInt = logger.levelToInt(this.config?.scimgateway?.log?.loglevel?.push || 'info')
|
|
2899
|
+
const sub = async (msgObj: Record<string, any>) => {
|
|
2900
|
+
if (logger.levelToInt(msgObj.level) < levelInt) return
|
|
2901
|
+
ws.send(`${JSON.stringify(msgObj)}`)
|
|
2902
|
+
}
|
|
2903
|
+
logger.subscribe(sub)
|
|
2904
|
+
;(ws as any)._sub = sub
|
|
2905
|
+
;(ws as any)._ip = data.ip
|
|
2906
|
+
},
|
|
2907
|
+
close: (ws) => {
|
|
2908
|
+
const sub = (ws as any)._sub
|
|
2909
|
+
const ip = (ws as any)._ip
|
|
2910
|
+
if (sub) {
|
|
2911
|
+
logger.unsubscribe(sub)
|
|
2912
|
+
}
|
|
2913
|
+
logger.info(`${gwName}[${pluginName}] remote logger disconnected from ip address ${ip}`)
|
|
2914
|
+
},
|
|
2915
|
+
message: () => {},
|
|
2790
2916
|
},
|
|
2791
2917
|
})
|
|
2792
2918
|
} else {
|
|
@@ -2871,6 +2997,8 @@ export class ScimGateway {
|
|
|
2871
2997
|
const bodyText = await streamToString(response.body)
|
|
2872
2998
|
res.end(bodyText)
|
|
2873
2999
|
}
|
|
3000
|
+
} else {
|
|
3001
|
+
res.end()
|
|
2874
3002
|
}
|
|
2875
3003
|
} catch (err: any) {
|
|
2876
3004
|
logger.error(`${gwName} internal error: ${err.message}`)
|
|
@@ -2882,48 +3010,53 @@ export class ScimGateway {
|
|
|
2882
3010
|
// create nodejs server and start listen
|
|
2883
3011
|
if (this.config.scimgateway.azureRelay?.enabled === true) {
|
|
2884
3012
|
// Azure Relay listener server
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
3013
|
+
(async () => {
|
|
3014
|
+
// @ts-expect-error: has no declaration
|
|
3015
|
+
const hycoPkg = await import('hyco-https')
|
|
3016
|
+
const hyco = hycoPkg.default || hycoPkg
|
|
3017
|
+
let url: URL = {} as URL
|
|
3018
|
+
try {
|
|
3019
|
+
url = new URL(this.config.scimgateway.azureRelay.connectionUrl) // Azure Relay hybrid connection URL: 'https://<namespace>.servicebus.windows.net/<hybrid-connection-name>'
|
|
3020
|
+
} catch (err: any) {
|
|
3021
|
+
logger.error(`${gwName}[${pluginName}] Azure Relay configuration scimgateway.azureRelay.connectionUrl - error: ${err.message}`)
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
const ns = url.hostname// <namespace>.servicebus.windows.net
|
|
3025
|
+
const path = url?.pathname?.replace(/^[\s\/]+|[\s\/]+$/g, '') // <hybrid-connection-name> - removing any leading/trailing whitespace and '/'
|
|
3026
|
+
const keyrule = this.config.scimgateway.azureRelay.keyRule || 'RootManageSharedAccessKey'
|
|
3027
|
+
const key = this.config.scimgateway.azureRelay.apiKey || '' // Azure Relay - SAS Primary Key
|
|
3028
|
+
const uri = hyco.createRelayListenUri(ns, path) // wss://<namespace>.servicebus.windows.net:443/$hc/<hybrid-connection-name>?sb-hc-action=listen
|
|
3029
|
+
|
|
3030
|
+
server = hyco.createRelayedServer(
|
|
3031
|
+
{
|
|
3032
|
+
server: uri,
|
|
3033
|
+
token: () => hyco.createRelayToken(uri, keyrule, key),
|
|
3034
|
+
},
|
|
3035
|
+
async (req: IncomingMessage, res: ServerResponse) => {
|
|
3036
|
+
doFetchApi(req, res)
|
|
3037
|
+
})
|
|
3038
|
+
server.listen()
|
|
3039
|
+
|
|
3040
|
+
{ // check if Azure Relay listener is working by sending a 5 sec delayed ping request
|
|
3041
|
+
let options = {
|
|
3042
|
+
connection: {
|
|
3043
|
+
options: {
|
|
3044
|
+
headers: {
|
|
3045
|
+
ServiceBusAuthorization: hyco.createRelayToken(uri, keyrule, key),
|
|
3046
|
+
},
|
|
2914
3047
|
},
|
|
2915
3048
|
},
|
|
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
3049
|
}
|
|
2925
|
-
|
|
2926
|
-
|
|
3050
|
+
setTimeout(async () => {
|
|
3051
|
+
try {
|
|
3052
|
+
if (!this.helperRest) this.helperRest = this.newHelperRest()
|
|
3053
|
+
await this.helperRest.doRequest('undefined', 'GET', `${this.config.scimgateway.azureRelay.connectionUrl}/ping`, null, null, options)
|
|
3054
|
+
} catch (err: any) {
|
|
3055
|
+
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}`)
|
|
3056
|
+
}
|
|
3057
|
+
}, 5 * 1000)
|
|
3058
|
+
}
|
|
3059
|
+
})()
|
|
2927
3060
|
} else {
|
|
2928
3061
|
// nodejs server
|
|
2929
3062
|
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