scimgateway 6.1.1 → 6.1.2
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 +52 -95
- package/lib/helper-rest.ts +244 -248
- package/lib/scimgateway.ts +99 -7
- package/lib/utils-scim.ts +7 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# SCIM Gateway
|
|
2
2
|
|
|
3
|
-
[](https://app.travis-ci.com/github/jelhub/scimgateway) [](https://www.npmjs.com/package/scimgateway)[](https://www.npmjs.com/package/scimgateway) [](https://elshaug.xyz/docs/scimgateway#disqus_thread) [](https://github.com/jelhub/scimgateway)
|
|
3
|
+
[](https://app.travis-ci.com/github/jelhub/scimgateway) [](https://www.npmjs.com/package/scimgateway)[](https://www.npmjs.com/package/scimgateway) [](https://elshaug.xyz/docs/scimgateway#disqus_thread) [](https://github.com/jelhub/scimgateway)
|
|
4
4
|
|
|
5
|
-
---
|
|
6
|
-
Author
|
|
5
|
+
---
|
|
6
|
+
**Author:** Jarle Elshaug
|
|
7
7
|
|
|
8
|
-
Validated through
|
|
8
|
+
**Validated through IdPs:**
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
* Symantec/Broadcom Identity Manager
|
|
11
|
+
* Microsoft Entra ID
|
|
12
|
+
* One Identity Manager
|
|
13
|
+
* Okta
|
|
14
|
+
* Omada
|
|
15
|
+
* SailPoint/IdentityNow
|
|
16
|
+
---
|
|
16
17
|
|
|
17
18
|
Latest news:
|
|
18
19
|
|
|
@@ -42,6 +43,8 @@ Latest news:
|
|
|
42
43
|
- Includes API Gateway for none SCIM/provisioning - becomes what you want it to become
|
|
43
44
|
- Running SCIM Gateway as a Docker container
|
|
44
45
|
|
|
46
|
+
---
|
|
47
|
+
|
|
45
48
|
## Overview
|
|
46
49
|
|
|
47
50
|
SCIM Gateway facilitates user management using the standardized REST-based SCIM 1.1 or 2.0 protocol, offering easier, more powerful, and consistent provisioning while avoiding vendor lock-in. Acting as a translator for incoming SCIM requests, the gateway seamlessly enables CRUD functionality (create, read, update, and delete) for users and groups. By implementing endpoint-specific protocols, it ensures provisioning across diverse destinations. With the gateway, your destinations become SCIM-compatible interfaces, streamlining integration and simplifying user management.
|
|
@@ -54,8 +57,8 @@ The following fully functional plugins are included for demonstration and produc
|
|
|
54
57
|
|
|
55
58
|
| Plugin | Endpoint Type | Description |
|
|
56
59
|
| :--- | :--- | :--- |
|
|
57
|
-
| **Loki** | NoSQL Database |
|
|
58
|
-
| **MongoDB** | NoSQL Database |
|
|
60
|
+
| **Loki** | NoSQL Database | Transforms the SCIM Gateway into a standalone SCIM endpoint utilizing the internal [LokiJS](https://github.com/techfort/LokiJS) database. Includes two test users and groups |
|
|
61
|
+
| **MongoDB** | NoSQL Database | Similar to the Loki plugin, but using an externally managed MongoDB database, showcasing multi-tenant and multi-endpoint capabilities via `baseEntity` |
|
|
59
62
|
| **Entra ID** | REST Webservices | Entra ID user provisioning via Microsoft Graph API |
|
|
60
63
|
| **SCIM** | REST Webservice | Using plugin Loki as a SCIM provisioning endpoint. May become a SCIM version-gateway (e.g., 1.1 => 2.0) |
|
|
61
64
|
| **API** | REST Webservices | A non-SCIM plugin demonstrating API Gateway functionality for custom REST specifications |
|
|
@@ -65,6 +68,7 @@ The following fully functional plugins are included for demonstration and produc
|
|
|
65
68
|
| **LDAP** | Directory | A fully functional LDAP plugin pre-configured for Microsoft Active Directory |
|
|
66
69
|
|
|
67
70
|
## Installation
|
|
71
|
+
To get started with SCIM Gateway, follow the instructions below.
|
|
68
72
|
|
|
69
73
|
#### Install Bun
|
|
70
74
|
|
|
@@ -91,7 +95,7 @@ index.ts, lib and config directories containing example plugins are copied to yo
|
|
|
91
95
|
Start a browser
|
|
92
96
|
|
|
93
97
|
http://localhost:8880/ping
|
|
94
|
-
=>
|
|
98
|
+
=> Returns a health check with a "hello" response
|
|
95
99
|
|
|
96
100
|
http://localhost:8880/Users
|
|
97
101
|
http://localhost:8880/Groups
|
|
@@ -126,7 +130,7 @@ The recommended upgrade method is to rename the existing package folder, perform
|
|
|
126
130
|
- Minor Upgrade: `bun install scimgateway`
|
|
127
131
|
- Major Upgrade: `bun install scimgateway@latest` (Use with caution, as it may break compatibility with existing custom plugins)
|
|
128
132
|
|
|
129
|
-
##### Avoid (re-)adding the
|
|
133
|
+
##### Avoid (re-)adding the example plugins created during `postinstall`
|
|
130
134
|
|
|
131
135
|
For production we do not need example plugins to be incuded by the `postinstall` job
|
|
132
136
|
Bun will by default exlude any `postinstall` jobs unless we have trusted the scimgateway package using the `bun pm trust scimgateway` that updates package.json `{ trustedDependencies: ["scimgateway"] }`
|
|
@@ -135,7 +139,7 @@ For Node.js (and also Bun), we might set the property `scimgateway_postinstall_s
|
|
|
135
139
|
|
|
136
140
|
## Configuration
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
The `index.ts` file defines the plugins to be started.
|
|
139
143
|
|
|
140
144
|
// start one or more plugins:
|
|
141
145
|
import './lib/plugin-entra-id.ts'
|
|
@@ -143,7 +147,7 @@ For Node.js (and also Bun), we might set the property `scimgateway_postinstall_s
|
|
|
143
147
|
|
|
144
148
|
|
|
145
149
|
Each endpoint plugin needs a TypeScript file (.ts) and a configuration file (.json).
|
|
146
|
-
|
|
150
|
+
They both must have the **same naming prefix**. For the Entra ID endpoint, the corresponding files are:
|
|
147
151
|
>lib\plugin-entra-id.ts
|
|
148
152
|
>config\plugin-entra-id.json
|
|
149
153
|
|
|
@@ -158,39 +162,39 @@ A plugin configuration file has two main JSON objects: `scimgateway` and `endpoi
|
|
|
158
162
|
}
|
|
159
163
|
}
|
|
160
164
|
|
|
161
|
-
`scimgateway`: Contains fixed attributes used by the core gateway functionality
|
|
165
|
+
`scimgateway`: Contains fixed attributes used by the core gateway functionality, such as port, logging, and authentication.
|
|
162
166
|
|
|
163
|
-
`endpoint`: Contains customized definitions required by the plugin code for communication with the destination system
|
|
167
|
+
`endpoint`: Contains customized definitions required by the plugin code for communication with the destination system, including host, port, and credentials.
|
|
164
168
|
|
|
165
|
-
- **port
|
|
169
|
+
- **port**: The gateway will listen on this port number. Clients, such as a provisioning server, will use this port to communicate with the gateway.
|
|
166
170
|
|
|
167
|
-
- **localhostonly
|
|
171
|
+
- **localhostonly**: Set to `true` to accept incoming requests only from localhost (127.0.0.1). Set to `false` to accept requests from all clients.
|
|
168
172
|
|
|
169
|
-
- **chainingBaseUrl
|
|
173
|
+
- **chainingBaseUrl**: The base URL for chaining another gateway, with the syntax `http(s)://host:port`. When defined, the gateway behaves like a reverse proxy, validating authorization unless PassThrough mode is enabled. See `Configuration notes` for details.
|
|
170
174
|
|
|
171
|
-
- **idleTimeout
|
|
175
|
+
- **idleTimeout**: The number of seconds to wait before timing out a connection due to inactivity. The default value is 120 seconds.
|
|
172
176
|
|
|
173
|
-
- **scim.version
|
|
177
|
+
- **scim.version**: Specifies the SCIM protocol version to use, either "1.1" or "2.0". The default is "2.0".
|
|
174
178
|
|
|
175
|
-
- **scim.skipTypeConvert
|
|
179
|
+
- **scim.skipTypeConvert**: When set to `true`, multivalue attributes with types (e.g., emails, phoneNumbers, ims, photos, addresses, entitlements, and x509Certificates, but not roles, groups, and members) will not be converted into "type converted objects" when sent to `modifyUser` and `createUser`. This is useful for simplifying attribute checks and for the `endpointMapper` method used by `plugin-ldap` and `plugin-entra-id`. For example:
|
|
176
180
|
|
|
177
181
|
"emails": {
|
|
178
182
|
"work": {"value": "jsmith@example.com", "type": "work"},
|
|
179
183
|
"home": {"value": "", "type": "home", "operation": "delete"},
|
|
180
184
|
"undefined": {"value": "jsmith@hotmail.com"}
|
|
181
185
|
}
|
|
182
|
-
|
|
183
|
-
skipTypeConvert set to true gives attribute "as-is": array, allow duplicate types including blank, but values to be deleted have been marked with "operation": "delete"
|
|
184
186
|
|
|
187
|
+
When `skipTypeConvert` is set to `true`, the attribute is provided "as-is" as an array, allowing duplicate types including blank types. Values to be deleted are marked with `"operation": "delete"`.
|
|
188
|
+
|
|
185
189
|
"emails": [
|
|
186
190
|
{"value": "jsmith@example.com", "type": "work"},
|
|
187
191
|
{"value": "john.smith.org", "type": "home", "operation": "delete"},
|
|
188
192
|
{"value": "jsmith@hotmail.com"}
|
|
189
193
|
]
|
|
190
194
|
|
|
191
|
-
- **scim.skipMetaLocation
|
|
195
|
+
- **scim.skipMetaLocation**: When set to `true`, the `meta.location` attribute, which contains the protocol and hostname from the request URL, will be excluded from the response (e.g., `"{...,meta":{"location":"https://my-company.com/<...>"}}`). This is useful when using a reverse proxy and not including the `X-Forwarded-Proto` and `X-Forwarded-Host` headers, as the originator will be the proxy and the internal protocol and hostname should not be exposed.
|
|
192
196
|
|
|
193
|
-
- **scim.groupMemberOfUser
|
|
197
|
+
- **scim.groupMemberOfUser**: When set to `true`, and the request body contains groups, the `groups` attribute will remain on the user object (groups are members of the user). The default behavior is for the user to be a member of the groups, which uses the `modifyGroup` method to maintain group members.
|
|
194
198
|
|
|
195
199
|
- **scim.usePutSoftSync** - true or false, default false. `PUT /Users/bjensen` will replace the user bjensen with body content. If set to `true`, only PUT body content will be replaced. Any additional existing user attributes and groups supported by plugin will remain as-is.
|
|
196
200
|
|
|
@@ -1169,36 +1173,6 @@ Endpoint configuration example:
|
|
|
1169
1173
|
|
|
1170
1174
|
For details, please see section "CA Identity Manager as IdP using SCIM Gateway"
|
|
1171
1175
|
|
|
1172
|
-
## SCIM Gateway REST API
|
|
1173
|
-
|
|
1174
|
-
Create = POST http://localhost:8880/Users
|
|
1175
|
-
(body contains the user information)
|
|
1176
|
-
|
|
1177
|
-
Update = PATCH http://localhost:8880/Users/<id>
|
|
1178
|
-
(body contains the attributes to be updated)
|
|
1179
|
-
|
|
1180
|
-
Search/Read = GET http://localhost:8880/Users?userName eq
|
|
1181
|
-
"userID"&attributes=<comma separated list of scim-schema defined attributes>
|
|
1182
|
-
|
|
1183
|
-
Search/explore all users:
|
|
1184
|
-
GET http://localhost:8880/Users?attributes=userName
|
|
1185
|
-
|
|
1186
|
-
Delete = DELETE http://localhost:8880/Users/<id>
|
|
1187
|
-
|
|
1188
|
-
Discovery:
|
|
1189
|
-
|
|
1190
|
-
GET http://localhost:8880/ServiceProviderConfigs
|
|
1191
|
-
Specification compliance, authentication schemes, data models.
|
|
1192
|
-
|
|
1193
|
-
GET http://localhost:8880/Schemas
|
|
1194
|
-
Introspect resources and attribute extensions.
|
|
1195
|
-
|
|
1196
|
-
Note:
|
|
1197
|
-
|
|
1198
|
-
- userName (mandatory) = UserID
|
|
1199
|
-
- id (mandatory) = Unique id. Could be set to the same as UserID but don't have to.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
1176
|
## API Gateway
|
|
1203
1177
|
|
|
1204
1178
|
SCIM Gateway also works as an API Gateway when using url `/api` or `/<baseEntity>/api`
|
|
@@ -1213,35 +1187,33 @@ Following methods for the none SCIM based api-plugin are supported:
|
|
|
1213
1187
|
PATCH /api/{id} + body
|
|
1214
1188
|
DELETE /api/{id}
|
|
1215
1189
|
|
|
1216
|
-
These methods can also be
|
|
1190
|
+
These methods can also be included in standard SCIM plugins
|
|
1217
1191
|
Please see example plugin: **plugin-api.ts**
|
|
1218
1192
|
|
|
1219
|
-
|
|
1220
1193
|
## How to build your own plugins
|
|
1221
|
-
For
|
|
1194
|
+
For coding editor you may use [Visual Studio Code](https://code.visualstudio.com/ "Visual Studio Code")
|
|
1222
1195
|
|
|
1223
1196
|
Preparation:
|
|
1224
1197
|
|
|
1225
1198
|
* Copy "best matching" example plugin e.g. `lib\plugin-mssql.ts` and `config\plugin-mssql.json` and rename both copies to your plugin name prefix e.g. plugin-mine.ts and plugin-mine.json
|
|
1226
1199
|
* Edit plugin-mine.json and define a unique port number for the gateway setting
|
|
1227
|
-
* Edit index.ts and include your plugin in the startup e.g. `
|
|
1228
|
-
* Start SCIM Gateway and verify
|
|
1200
|
+
* Edit index.ts and include your plugin in the startup e.g. `import './lib/plugin-mine.ts'`
|
|
1201
|
+
* Start SCIM Gateway and verify
|
|
1229
1202
|
|
|
1230
|
-
|
|
1203
|
+
We are now ready for custom coding by editing plugin-mine.ts
|
|
1231
1204
|
Coding should be done step by step and each step should be verified and tested before starting the next
|
|
1232
1205
|
|
|
1233
1206
|
1. **Turn off group functionality** - getGroups to return empty response (gateway automatically use getGroups for some of the methods if groups not included)
|
|
1234
1207
|
Please see plugin-saphana that do not use groups.
|
|
1235
1208
|
2. **getUsers** (test provisioning retrieve all accounts and single account)
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1209
|
+
3. **createUser** (test provisioning new account)
|
|
1210
|
+
4. **deleteUser** (test provisioning delete account)
|
|
1211
|
+
5. **modifyUser** (test provisioning modify account)
|
|
1212
|
+
6. **Turn on group functionality** - getGroups having logic for returning groups if groups are supported
|
|
1240
1213
|
7. **getGroups** (test provisioning retrieve groups)
|
|
1241
1214
|
8. **modifyGroup** (test provisioning modify group members)
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1215
|
+
9. **createGroup** (test provisioning new group)
|
|
1216
|
+
10. **deleteGroup** (test provisioning delete account)
|
|
1245
1217
|
|
|
1246
1218
|
Template used by CA Provisioning role should only include endpoint supported attributes defined in our plugin. Template should therefore have no links to global user for none supported attributes (e.g. remove %UT% from "Job Title" if our endpoint/code do not support title)
|
|
1247
1219
|
|
|
@@ -1293,33 +1265,14 @@ advanced options - **Synchronized** = enabled (toggled on)
|
|
|
1293
1265
|
Plugins should have following initialization:
|
|
1294
1266
|
|
|
1295
1267
|
// start - mandatory plugin initialization
|
|
1296
|
-
|
|
1297
|
-
try {
|
|
1298
|
-
return (await import('scimgateway')).ScimGateway
|
|
1299
|
-
} catch (err) {
|
|
1300
|
-
const source = './scimgateway.ts'
|
|
1301
|
-
return (await import(source)).ScimGateway
|
|
1302
|
-
}
|
|
1303
|
-
})()
|
|
1268
|
+
import { ScimGateway, HelperRest } from 'scimgateway'
|
|
1304
1269
|
const scimgateway = new ScimGateway()
|
|
1270
|
+
const helper = new HelperRest(scimgateway)
|
|
1305
1271
|
const config = scimgateway.getConfig()
|
|
1306
1272
|
scimgateway.authPassThroughAllowed = false
|
|
1307
1273
|
// end - mandatory plugin initialization
|
|
1308
|
-
|
|
1309
|
-
If using REST, we could also include the HelperRest:
|
|
1310
1274
|
|
|
1311
|
-
|
|
1312
|
-
...
|
|
1313
|
-
const HelperRest: typeof import('scimgateway').HelperRest = await (async () => {
|
|
1314
|
-
try {
|
|
1315
|
-
return (await import('scimgateway')).HelperRest
|
|
1316
|
-
} catch (err) {
|
|
1317
|
-
const source = './scimgateway.ts'
|
|
1318
|
-
return (await import(source)).HelperRest
|
|
1319
|
-
}
|
|
1320
|
-
})()
|
|
1321
|
-
...
|
|
1322
|
-
// end - mandatory plugin initialization
|
|
1275
|
+
HelperRest could included and used by REST plugins
|
|
1323
1276
|
|
|
1324
1277
|
Plugins should include following SCIM Gateway methods:
|
|
1325
1278
|
|
|
@@ -1350,6 +1303,12 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1350
1303
|
|
|
1351
1304
|
## Change log
|
|
1352
1305
|
|
|
1306
|
+
### v6.1.2
|
|
1307
|
+
|
|
1308
|
+
[Fixed]
|
|
1309
|
+
- SMTP mail functionality failed because of an updated dependency
|
|
1310
|
+
- endpointMapper failed when `mapTo` included multiple comma-separated attributes and one of them was a multivalued attribute, e.g. `{ "mail": { "mapTo": "userName,emails.work.value" } }`
|
|
1311
|
+
|
|
1353
1312
|
### v6.1.1
|
|
1354
1313
|
|
|
1355
1314
|
[Fixed]
|
|
@@ -3572,5 +3531,3 @@ This plugin now replace previous `plugin-testmode`
|
|
|
3572
3531
|
|
|
3573
3532
|
### v0.2.0
|
|
3574
3533
|
Initial version
|
|
3575
|
-
|
|
3576
|
-
|
package/lib/helper-rest.ts
CHANGED
|
@@ -34,109 +34,137 @@ export class HelperRest {
|
|
|
34
34
|
this.scimgateway = scimgateway
|
|
35
35
|
this.idleTimeout = (scimgateway as any)?.config?.scimgateway.idleTimeout || 120
|
|
36
36
|
this.idleTimeout = this.idleTimeout - 1
|
|
37
|
-
if (optionalEntities && optionalEntities.entity) this.config_entity = utils.copyObj(optionalEntities.entity)
|
|
38
|
-
else this.config_entity = utils.copyObj(scimgateway.getConfig())?.entity
|
|
39
|
-
|
|
40
|
-
let connectionFound = false
|
|
37
|
+
if (optionalEntities && optionalEntities.entity) this.config_entity = utils.copyObj(optionalEntities.entity) ?? {}
|
|
38
|
+
else this.config_entity = utils.copyObj(scimgateway.getConfig())?.entity ?? {}
|
|
39
|
+
|
|
41
40
|
for (const baseEntity in this.config_entity) {
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
const type = this.config_entity[baseEntity].connection?.auth?.type
|
|
41
|
+
const connectionObj = this.config_entity[baseEntity]?.connection
|
|
42
|
+
if (connectionObj) {
|
|
43
|
+
const type = connectionObj.auth?.type
|
|
46
44
|
if (type === 'oauthJwtBearer' || type === 'oauth') {
|
|
47
45
|
// set default baseUrls for Entra ID and Google if not already defined
|
|
48
|
-
if (
|
|
49
|
-
if (!
|
|
50
|
-
|
|
51
|
-
} else if (
|
|
52
|
-
|
|
46
|
+
if (connectionObj.auth?.options?.azureTenantId) { // Entra ID, setting baseUrls to graph
|
|
47
|
+
if (!connectionObj.baseUrls) {
|
|
48
|
+
connectionObj.baseUrls = [this.graphUrl]
|
|
49
|
+
} else if (connectionObj.baseUrls?.length < 1) {
|
|
50
|
+
connectionObj.baseUrls = [this.graphUrl]
|
|
53
51
|
}
|
|
54
|
-
} else if (
|
|
55
|
-
if (!
|
|
56
|
-
|
|
57
|
-
} else if (
|
|
58
|
-
|
|
52
|
+
} else if (connectionObj.auth?.options?.serviceAccountKeyFile) { // Google, setting baseUrls to googleapis
|
|
53
|
+
if (!connectionObj.baseUrls) {
|
|
54
|
+
connectionObj.baseUrls = [this.googleUrl]
|
|
55
|
+
} else if (connectionObj.baseUrls?.length < 1) {
|
|
56
|
+
connectionObj.baseUrls = [this.googleUrl]
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
}
|
|
63
61
|
}
|
|
64
|
-
let errMsg = ''
|
|
65
|
-
if (!entityFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>\''
|
|
66
|
-
else if (!connectionFound) errMsg = 'HelperRest initialization error: missing configuration \'endpoint.entity.<name>.connection\''
|
|
67
|
-
if (errMsg) this.scimgateway.logError('undefined', errMsg)
|
|
68
62
|
}
|
|
69
63
|
|
|
70
64
|
/**
|
|
71
65
|
* getAccessToken returns oauth accesstoken object
|
|
72
66
|
* @param baseEntity
|
|
67
|
+
* @param connectionObj endpoint.entity.baseEntity.connection
|
|
73
68
|
* @param ctx
|
|
74
|
-
* @returns
|
|
69
|
+
* @returns { access_token: 'xxx', token_type: 'Bearer/Basic', validTo: 'xxx' }
|
|
75
70
|
*/
|
|
76
|
-
public async getAccessToken(baseEntity: string, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
|
|
71
|
+
public async getAccessToken(baseEntity: string, connectionObj: Record<string, any>, ctx?: Record<string, any> | undefined) { // public in case token is needed for other logic e.g. sending mail
|
|
77
72
|
await this.lock.acquire()
|
|
78
73
|
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
|
|
79
|
-
if (this._serviceClient[baseEntity]
|
|
80
|
-
&& (this._serviceClient[baseEntity].accessToken.validTo >= d + 30)) { // avoid simultaneously token requests
|
|
74
|
+
if (this._serviceClient[baseEntity]?.accessToken?.validTo >= d + 30) { // avoid simultaneously token requests
|
|
81
75
|
this.lock.release()
|
|
82
76
|
return this._serviceClient[baseEntity].accessToken
|
|
83
77
|
}
|
|
84
78
|
|
|
85
79
|
const action = 'getAccessToken'
|
|
86
|
-
|
|
87
|
-
const serviceAccountKeyFile =
|
|
88
|
-
const azureTenantId =
|
|
80
|
+
if (typeof connectionObj !== 'object' || connectionObj === null) connectionObj = {}
|
|
81
|
+
const serviceAccountKeyFile = connectionObj.auth?.options?.serviceAccountKeyFile
|
|
82
|
+
const azureTenantId = connectionObj.auth?.options?.azureTenantId
|
|
89
83
|
let tokenUrl: string
|
|
90
84
|
let form: Record<string, any>
|
|
91
85
|
let resource = ''
|
|
92
86
|
|
|
93
87
|
try {
|
|
94
|
-
const urlObj = new URL(
|
|
88
|
+
const urlObj = new URL(connectionObj.baseUrls[0])
|
|
95
89
|
resource = urlObj.origin
|
|
96
90
|
} catch (err) { void 0 }
|
|
97
91
|
if (azureTenantId) {
|
|
98
92
|
tokenUrl = `https://login.microsoftonline.com/${azureTenantId}/oauth2/v2.0/token`
|
|
99
|
-
if (resource)
|
|
100
|
-
} else tokenUrl =
|
|
93
|
+
if (resource) connectionObj.auth.options.scope = resource + '/.default' // "https://graph.microsoft.com/.default"
|
|
94
|
+
} else tokenUrl = connectionObj.auth?.options?.tokenUrl
|
|
101
95
|
|
|
102
96
|
try {
|
|
103
|
-
switch (
|
|
97
|
+
switch (connectionObj.auth?.type) {
|
|
98
|
+
case 'basic':
|
|
99
|
+
if (!connectionObj.auth?.options?.username || !connectionObj.auth?.options?.password) {
|
|
100
|
+
const err = new Error(`auth.type 'basic' - missing connection configuration: auth.options.username/password`)
|
|
101
|
+
throw err
|
|
102
|
+
}
|
|
103
|
+
this.lock.release()
|
|
104
|
+
return {
|
|
105
|
+
access_token: Buffer.from(`${connectionObj.auth.options.username}:${connectionObj.auth.options.password}`).toString('base64'),
|
|
106
|
+
token_type: 'Basic',
|
|
107
|
+
}
|
|
104
108
|
case 'oauth':
|
|
109
|
+
if (!connectionObj.auth?.options?.clientId || !connectionObj.auth?.options?.clientSecret) {
|
|
110
|
+
const err = new Error(`auth.type 'oauth' - missing connection configuration: auth.options.clientId/clientSecret`)
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
105
113
|
form = {
|
|
106
114
|
grant_type: 'client_credentials',
|
|
107
|
-
client_id:
|
|
108
|
-
client_secret:
|
|
115
|
+
client_id: connectionObj.auth.options.clientId,
|
|
116
|
+
client_secret: connectionObj.auth.options.clientSecret,
|
|
109
117
|
}
|
|
110
|
-
if (
|
|
111
|
-
if (
|
|
118
|
+
if (connectionObj.auth.options.scope) form.scope = connectionObj.auth.options.scope // required using Entra ID /oauth2/v2.0/token
|
|
119
|
+
if (connectionObj.auth.options.resource) resource = connectionObj.auth.options.resource // required using Entra ID /oauth2/token
|
|
112
120
|
|
|
113
121
|
break
|
|
114
122
|
|
|
115
123
|
case 'token':
|
|
116
|
-
tokenUrl
|
|
124
|
+
if (!connectionObj.auth?.options?.tokenUrl || !connectionObj.auth?.options?.password) {
|
|
125
|
+
const err = new Error(`missing connection configuration: auth.options.tokenUrl/password`)
|
|
126
|
+
throw err
|
|
127
|
+
}
|
|
128
|
+
tokenUrl = connectionObj.auth.options.tokenUrl
|
|
117
129
|
form = { // example username/password in body
|
|
118
|
-
username:
|
|
119
|
-
password:
|
|
130
|
+
username: connectionObj.auth.options.username,
|
|
131
|
+
password: connectionObj.auth.options.password,
|
|
120
132
|
}
|
|
121
133
|
break
|
|
122
134
|
|
|
135
|
+
case 'bearer':
|
|
136
|
+
if (!connectionObj.auth?.options?.token) {
|
|
137
|
+
const err = new Error(`missing connection configuration: auth.options.token`)
|
|
138
|
+
throw err
|
|
139
|
+
}
|
|
140
|
+
this.lock.release()
|
|
141
|
+
return {
|
|
142
|
+
access_token: Buffer.from(connectionObj.auth.options.token).toString('base64'),
|
|
143
|
+
token_type: 'Bearer',
|
|
144
|
+
}
|
|
145
|
+
|
|
123
146
|
case 'oauthSamlBearer':
|
|
124
|
-
|
|
147
|
+
if (!connectionObj.auth?.options?.samlPayload?.clientId || !connectionObj.auth?.options?.samlPayload?.companyId
|
|
148
|
+
|| !connectionObj.auth?.options?.tls?.key) {
|
|
149
|
+
const err = new Error(`auth.type 'oauthSamlBearer' - missing connection configuration: auth.options.tls and/or options.samlPayload.clientId/companyId`)
|
|
150
|
+
throw err
|
|
151
|
+
}
|
|
152
|
+
tokenUrl = connectionObj.auth.options.tokenUrl
|
|
125
153
|
const context = null
|
|
126
|
-
const cert = fs.readFileSync(
|
|
127
|
-
const key = fs.readFileSync(
|
|
154
|
+
const cert = fs.readFileSync(connectionObj.auth.options.tls.cert).toString()
|
|
155
|
+
const key = fs.readFileSync(connectionObj.auth.options.tls.key).toString()
|
|
128
156
|
|
|
129
157
|
const tokenEndpoint = tokenUrl
|
|
130
158
|
const delay = 1
|
|
131
159
|
|
|
132
160
|
// mandatory: clientId, companyId and nameId
|
|
133
|
-
const clientId =
|
|
134
|
-
const companyId =
|
|
135
|
-
const nameId =
|
|
136
|
-
const userIdentifierFormat =
|
|
137
|
-
const lifetime =
|
|
138
|
-
const issuer =
|
|
139
|
-
const audience =
|
|
161
|
+
const clientId = connectionObj.auth.options.samlPayload.clientId
|
|
162
|
+
const companyId = connectionObj.auth.options.samlPayload.companyId
|
|
163
|
+
const nameId = connectionObj.auth.options.samlPayload.nameId
|
|
164
|
+
const userIdentifierFormat = connectionObj.auth.options.samlPayload.userIdentifierFormat || 'userName'
|
|
165
|
+
const lifetime = connectionObj.auth.options.samlPayload.lifetime || 3600
|
|
166
|
+
const issuer = connectionObj.auth.options.samlPayload.clientId || `https://scimgateway.${this.scimgateway.pluginName}.com`
|
|
167
|
+
const audience = connectionObj.auth.options.samlPayload.audience || `scimgateway/${this.scimgateway.pluginName}`
|
|
140
168
|
|
|
141
169
|
form = {
|
|
142
170
|
token_url: tokenUrl,
|
|
@@ -149,16 +177,19 @@ export class HelperRest {
|
|
|
149
177
|
break
|
|
150
178
|
|
|
151
179
|
case 'oauthJwtBearer':
|
|
180
|
+
// auth.options.azureTenantId => Microsoft Entra ID
|
|
181
|
+
// auth.options.serviceAccountKeyFile => Google Service Account
|
|
182
|
+
// also support custom using tokenUrl/jwtPayload
|
|
152
183
|
let jwtClaims: jose.JWTPayload | Record<string, any>
|
|
153
184
|
let jwtHeaders: jose.JWTHeaderParameters
|
|
154
185
|
|
|
155
186
|
if (azureTenantId) { // Microsoft Entra ID
|
|
156
|
-
if (
|
|
187
|
+
if (connectionObj.auth?.options?.fedCred?.issuer) { // federated credentials
|
|
157
188
|
const now = Date.now()
|
|
158
189
|
const jwtPayload: jose.JWTPayload = {
|
|
159
|
-
iss:
|
|
160
|
-
sub:
|
|
161
|
-
name:
|
|
190
|
+
iss: connectionObj.auth?.options?.fedCred?.issuer, // entra id federated credentials issuer - scimgateway base URL, e.g. https://scimgateway.my-company.com
|
|
191
|
+
sub: connectionObj.auth?.options?.fedCred?.subject, // entra id application object id - client id
|
|
192
|
+
name: connectionObj.auth?.options?.fedCred?.name, // entra id federated credentials unique name e.g. plugin-entra-id
|
|
162
193
|
aud: 'api://AzureADTokenExchange', // entra id federated credentials audience
|
|
163
194
|
// below is not used by entra id federated credentials token-generation - could be skipped
|
|
164
195
|
iat: Math.floor(now / 1000) - 60,
|
|
@@ -184,8 +215,8 @@ export class HelperRest {
|
|
|
184
215
|
|
|
185
216
|
form = {
|
|
186
217
|
grant_type: 'client_credentials',
|
|
187
|
-
scope:
|
|
188
|
-
client_id:
|
|
218
|
+
scope: connectionObj.auth.options.scope, // "https://graph.microsoft.com/.default"
|
|
219
|
+
client_id: connectionObj.auth?.options?.fedCred?.subject,
|
|
189
220
|
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
190
221
|
client_assertion: await new jose.SignJWT(jwtClaims)
|
|
191
222
|
.setProtectedHeader(jwtHeaders)
|
|
@@ -204,34 +235,34 @@ export class HelperRest {
|
|
|
204
235
|
}, ttl * 1000)
|
|
205
236
|
})()
|
|
206
237
|
}
|
|
207
|
-
this.scimgateway.jwk.issuer =
|
|
238
|
+
this.scimgateway.jwk.issuer = connectionObj.auth?.options?.fedCred?.issuer // all baseEntities should use same issuer
|
|
208
239
|
} else { // standard certificate
|
|
209
|
-
if (!
|
|
210
|
-
throw new Error(`auth type '${
|
|
240
|
+
if (!connectionObj.auth?.options?.tls?.cert) {
|
|
241
|
+
throw new Error(`auth type '${connectionObj.auth?.type}' - missing options.tls.key/cert configuration`)
|
|
211
242
|
}
|
|
212
|
-
let privateKey =
|
|
213
|
-
let cert =
|
|
214
|
-
let certPem =
|
|
243
|
+
let privateKey = connectionObj.auth?.options?.tls?._key || ''
|
|
244
|
+
let cert = connectionObj.auth?.options?.tls?._cert || ''
|
|
245
|
+
let certPem = connectionObj.auth?.options?.tls?._certPem || ''
|
|
215
246
|
if (!privateKey || !cert) {
|
|
216
|
-
const privateKeyPem = fs.readFileSync(
|
|
217
|
-
certPem = fs.readFileSync(
|
|
247
|
+
const privateKeyPem = fs.readFileSync(connectionObj.auth.options.tls.key, 'utf-8') || ''
|
|
248
|
+
certPem = fs.readFileSync(connectionObj.auth.options.tls.cert, 'utf-8') || ''
|
|
218
249
|
if (privateKeyPem) {
|
|
219
250
|
privateKey = createPrivateKey(privateKeyPem) // PEM => KeyObject
|
|
220
|
-
|
|
251
|
+
connectionObj.auth.options.tls._key = privateKey
|
|
221
252
|
}
|
|
222
253
|
if (certPem) {
|
|
223
254
|
cert = createPublicKey(certPem)
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
connectionObj.auth.options.tls._cert = cert
|
|
256
|
+
connectionObj.auth.options.tls._certPem = certPem
|
|
226
257
|
}
|
|
227
258
|
}
|
|
228
259
|
if (!privateKey || !cert) {
|
|
229
|
-
throw new Error(`auth type '${
|
|
260
|
+
throw new Error(`auth type '${connectionObj.auth?.type}' - missing options.tls.key/cert file content`)
|
|
230
261
|
}
|
|
231
262
|
|
|
232
263
|
const jwtPayload: jose.JWTPayload = {
|
|
233
|
-
iss:
|
|
234
|
-
sub:
|
|
264
|
+
iss: connectionObj.auth?.options?.clientId,
|
|
265
|
+
sub: connectionObj.auth?.options?.clientId,
|
|
235
266
|
aud: `https://login.microsoftonline.com/${azureTenantId}/v2.0`,
|
|
236
267
|
iat: Math.floor(Date.now() / 1000) - 60,
|
|
237
268
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
@@ -251,8 +282,8 @@ export class HelperRest {
|
|
|
251
282
|
|
|
252
283
|
form = {
|
|
253
284
|
grant_type: 'client_credentials',
|
|
254
|
-
scope:
|
|
255
|
-
client_id:
|
|
285
|
+
scope: connectionObj.auth.options.scope, // "https://graph.microsoft.com/.default"
|
|
286
|
+
client_id: connectionObj.auth?.options?.clientId,
|
|
256
287
|
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
257
288
|
client_assertion: await new jose.SignJWT(jwtClaims)
|
|
258
289
|
.setProtectedHeader(jwtHeaders)
|
|
@@ -260,35 +291,35 @@ export class HelperRest {
|
|
|
260
291
|
}
|
|
261
292
|
}
|
|
262
293
|
} else if (serviceAccountKeyFile) { // Google - using Service Account key json-file
|
|
263
|
-
if (!
|
|
264
|
-
const err = new Error(`auth type '${
|
|
294
|
+
if (!connectionObj.auth?.options?.jwtPayload?.scope || !connectionObj.auth?.options?.jwtPayload?.subject) {
|
|
295
|
+
const err = new Error(`auth type '${connectionObj.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.jwtPayload.scope/subject`)
|
|
265
296
|
throw err
|
|
266
297
|
}
|
|
267
|
-
let gkey: Record<string, any> =
|
|
298
|
+
let gkey: Record<string, any> = connectionObj.auth?.options?._gkey
|
|
268
299
|
if (!gkey) {
|
|
269
300
|
gkey = await (async () => {
|
|
270
301
|
try {
|
|
271
302
|
const jsonObject = await import(serviceAccountKeyFile, { assert: { type: 'json' } })
|
|
272
303
|
return jsonObject.default // access the object via the `default` property
|
|
273
304
|
} catch (err: any) {
|
|
274
|
-
throw new Error(`auth type '${
|
|
305
|
+
throw new Error(`auth type '${connectionObj.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
|
|
275
306
|
}
|
|
276
307
|
})()
|
|
277
|
-
|
|
308
|
+
connectionObj.auth.options._gkey = gkey
|
|
278
309
|
}
|
|
279
310
|
|
|
280
311
|
tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
|
|
281
312
|
const privateKey = createPrivateKey(gkey.private_key) // PEM => KeyObject
|
|
282
313
|
const jwtPayload: jose.JWTPayload = {
|
|
283
314
|
iss: gkey.client_email, // service account email/user
|
|
284
|
-
sub:
|
|
315
|
+
sub: connectionObj.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
|
|
285
316
|
aud: gkey.token_uri,
|
|
286
317
|
iat: Math.floor(Date.now() / 1000) - 60, // issued at
|
|
287
318
|
exp: Math.floor(Date.now() / 1000) + 3600, // expiration time
|
|
288
319
|
}
|
|
289
320
|
jwtClaims = {
|
|
290
321
|
...jwtPayload,
|
|
291
|
-
scope:
|
|
322
|
+
scope: connectionObj.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
|
|
292
323
|
}
|
|
293
324
|
jwtHeaders = {
|
|
294
325
|
alg: 'RS256',
|
|
@@ -304,25 +335,25 @@ export class HelperRest {
|
|
|
304
335
|
}
|
|
305
336
|
} else {
|
|
306
337
|
// standard JWT - requires all configuation: tokenUrl, jwtPayload and tls.key
|
|
307
|
-
if (!
|
|
308
|
-
|| !
|
|
309
|
-
|| typeof
|
|
310
|
-
throw new Error(`auth.type '${
|
|
338
|
+
if (!connectionObj.auth?.options?.tokenUrl
|
|
339
|
+
|| !connectionObj.auth?.options?.jwtPayload
|
|
340
|
+
|| typeof connectionObj.auth?.options?.jwtPayload !== 'object') {
|
|
341
|
+
throw new Error(`auth.type '${connectionObj.auth?.type}' (no azureTenantId/serviceAccountKeyFile using raw) - missing connection configuration: auth.options.tokenUrl/jwtPayload`)
|
|
311
342
|
}
|
|
312
|
-
if (!
|
|
313
|
-
throw new Error(`auth type '${
|
|
343
|
+
if (!connectionObj.auth?.options?.tls?.key) {
|
|
344
|
+
throw new Error(`auth type '${connectionObj.auth?.type}' (no azureTenantId/serviceAccountKeyFile using raw) - missing options.tls.key configuration`)
|
|
314
345
|
}
|
|
315
|
-
tokenUrl =
|
|
316
|
-
let privateKey =
|
|
346
|
+
tokenUrl = connectionObj.auth.options.tokenUrl
|
|
347
|
+
let privateKey = connectionObj.auth?.options?.tls?._key || ''
|
|
317
348
|
if (!privateKey) {
|
|
318
|
-
privateKey = fs.readFileSync(
|
|
349
|
+
privateKey = fs.readFileSync(connectionObj.auth.options.tls.key, 'utf-8') || ''
|
|
319
350
|
if (privateKey) {
|
|
320
351
|
privateKey = createPrivateKey(privateKey)
|
|
321
|
-
|
|
352
|
+
connectionObj.auth.options.tls._key = privateKey
|
|
322
353
|
}
|
|
323
354
|
}
|
|
324
355
|
|
|
325
|
-
let jwtPayload: jose.JWTPayload =
|
|
356
|
+
let jwtPayload: jose.JWTPayload = connectionObj.auth.options.jwtPayload
|
|
326
357
|
if (!jwtPayload.iat) jwtPayload.iat = Math.floor(Date.now() / 1000) - 60
|
|
327
358
|
if (!jwtPayload.exp) jwtPayload.exp = Math.floor(Date.now() / 1000) + 3600
|
|
328
359
|
|
|
@@ -345,18 +376,19 @@ export class HelperRest {
|
|
|
345
376
|
break
|
|
346
377
|
|
|
347
378
|
default:
|
|
348
|
-
|
|
379
|
+
// no auth or PassTrough
|
|
380
|
+
return {}
|
|
349
381
|
}
|
|
350
382
|
|
|
351
383
|
if (!tokenUrl) {
|
|
352
|
-
throw new Error(`auth type '${
|
|
384
|
+
throw new Error(`auth type '${connectionObj.auth?.type}' - missing tokenUrl`)
|
|
353
385
|
}
|
|
354
386
|
|
|
355
387
|
this.scimgateway.logDebug(baseEntity, `${action}: Retrieving accesstoken`)
|
|
356
388
|
const method = 'POST'
|
|
357
389
|
let connOpt: any = {}
|
|
358
|
-
if (
|
|
359
|
-
connOpt = utils.copyObj(
|
|
390
|
+
if (connectionObj.options && typeof connectionObj.options === 'object') {
|
|
391
|
+
connOpt = utils.copyObj(connectionObj.options)
|
|
360
392
|
}
|
|
361
393
|
if (!connOpt.headers) connOpt.headers = {}
|
|
362
394
|
connOpt.headers['Content-Type'] = 'application/x-www-form-urlencoded' // body must be query string formatted (no JSON)
|
|
@@ -371,7 +403,7 @@ export class HelperRest {
|
|
|
371
403
|
const err = new Error(`[${action}] Error message: ${jbody.error_description}`)
|
|
372
404
|
throw (err)
|
|
373
405
|
}
|
|
374
|
-
if (
|
|
406
|
+
if (connectionObj.auth?.type === 'token') { // in case response using token instead of access_token
|
|
375
407
|
if (jbody.token) jbody.access_token = jbody.token
|
|
376
408
|
else if (jbody.accessToken) jbody.access_token = jbody.accessToken
|
|
377
409
|
}
|
|
@@ -382,6 +414,7 @@ export class HelperRest {
|
|
|
382
414
|
|
|
383
415
|
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
|
|
384
416
|
jbody.validTo = d + parseInt(jbody.expires_in) // instead of using expires_on (clock may not be in sync with NTP, AAD default expires_in = 3600 seconds)
|
|
417
|
+
jbody.token_type = jbody.token_type || 'Bearer'
|
|
385
418
|
|
|
386
419
|
this.lock.release()
|
|
387
420
|
return jbody
|
|
@@ -400,8 +433,9 @@ export class HelperRest {
|
|
|
400
433
|
* @param ctx optional, ctx included if using Auth PassThrough
|
|
401
434
|
* @returns client.options needed for connect
|
|
402
435
|
*/
|
|
403
|
-
private async getServiceClient(baseEntity: string, method: string, path: string, opt?: any, ctx?: any) {
|
|
436
|
+
private async getServiceClient(baseEntity: string, connectionObj: Record<string, any>, method: string, path: string, opt?: any, ctx?: any) {
|
|
404
437
|
const action = 'getServiceClient'
|
|
438
|
+
if (typeof connectionObj !== 'object' || connectionObj === null) connectionObj = {}
|
|
405
439
|
let urlObj: any
|
|
406
440
|
if (!path) path = ''
|
|
407
441
|
try {
|
|
@@ -412,15 +446,15 @@ export class HelperRest {
|
|
|
412
446
|
//
|
|
413
447
|
if (this._serviceClient[baseEntity]) { // serviceClient already exist - token specific
|
|
414
448
|
this.scimgateway.logDebug(baseEntity, `${action}: Using existing client`)
|
|
415
|
-
if (this._serviceClient[baseEntity].accessToken) {
|
|
449
|
+
if (this._serviceClient[baseEntity].accessToken?.validTo) {
|
|
416
450
|
// check if token refresh is needed when using oauth
|
|
417
451
|
const d = Math.floor(Date.now() / 1000) // seconds (unix time)
|
|
418
452
|
if (this._serviceClient[baseEntity].accessToken.validTo < d + 30) { // less than 30 sec before token expiration
|
|
419
453
|
this.scimgateway.logDebug(baseEntity, `${action}: Accesstoken about to expire in ${this._serviceClient[baseEntity].accessToken.validTo - d} seconds`)
|
|
420
454
|
try {
|
|
421
|
-
const accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
455
|
+
const accessToken = await this.getAccessToken(baseEntity, connectionObj, ctx)
|
|
422
456
|
this._serviceClient[baseEntity].accessToken = accessToken
|
|
423
|
-
this._serviceClient[baseEntity].options.headers['Authorization'] =
|
|
457
|
+
this._serviceClient[baseEntity].options.headers['Authorization'] = `${accessToken.token_type} ${accessToken.access_token}`
|
|
424
458
|
} catch (err) {
|
|
425
459
|
delete this._serviceClient[baseEntity]
|
|
426
460
|
const newErr = err
|
|
@@ -436,13 +470,13 @@ export class HelperRest {
|
|
|
436
470
|
const err = new Error(`unsupported baseEntity: ${baseEntity}`)
|
|
437
471
|
throw err
|
|
438
472
|
}
|
|
439
|
-
if (!
|
|
440
|
-
const err = new Error(`missing configuration
|
|
473
|
+
if (!connectionObj.baseUrls || !Array.isArray(connectionObj.baseUrls) || connectionObj.baseUrls.length < 1) {
|
|
474
|
+
const err = new Error(`missing connection configuration: baseUrls`)
|
|
441
475
|
throw err
|
|
442
476
|
}
|
|
443
|
-
urlObj = new URL(
|
|
477
|
+
urlObj = new URL(connectionObj.baseUrls[0])
|
|
444
478
|
const param: any = {
|
|
445
|
-
baseUrl:
|
|
479
|
+
baseUrl: connectionObj.baseUrls[0],
|
|
446
480
|
options: {
|
|
447
481
|
json: true, // json-object response instead of string
|
|
448
482
|
headers: {
|
|
@@ -460,92 +494,47 @@ export class HelperRest {
|
|
|
460
494
|
|
|
461
495
|
let orgConnection: any
|
|
462
496
|
if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
|
|
463
|
-
let org =
|
|
497
|
+
let org = connectionObj
|
|
464
498
|
orgConnection = utils.copyObj(org)
|
|
465
499
|
if (!org) org = {}
|
|
466
500
|
org = utils.extendObj(org, opt.connection)
|
|
467
501
|
}
|
|
468
502
|
|
|
469
503
|
// may use configuration type='oauth' and auto corrected to 'oauthJwtBearer'
|
|
470
|
-
if (
|
|
471
|
-
if (
|
|
472
|
-
if (
|
|
473
|
-
&&
|
|
474
|
-
&&
|
|
475
|
-
)
|
|
476
|
-
} else if (
|
|
477
|
-
|
|
504
|
+
if (connectionObj.auth?.type == 'oauth') {
|
|
505
|
+
if (connectionObj.auth?.options?.azureTenantId) {
|
|
506
|
+
if (connectionObj.auth.options?.tls?.cert
|
|
507
|
+
&& connectionObj.auth.options?.tls?.key
|
|
508
|
+
&& connectionObj.auth.options.clientId
|
|
509
|
+
) connectionObj.auth.type = 'oauthJwtBearer'
|
|
510
|
+
} else if (connectionObj.auth?.options?.serviceAccountKeyFile) {
|
|
511
|
+
connectionObj.auth.type = 'oauthJwtBearer'
|
|
478
512
|
}
|
|
479
513
|
}
|
|
480
514
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
param.options.headers['Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.auth.options.username}:${this.config_entity[baseEntity].connection.auth.options.password}`).toString('base64')
|
|
488
|
-
break
|
|
489
|
-
case 'oauth':
|
|
490
|
-
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
|
|
491
|
-
const err = new Error(`auth.type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
|
|
492
|
-
throw err
|
|
493
|
-
}
|
|
494
|
-
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
495
|
-
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
496
|
-
break
|
|
497
|
-
case 'token':
|
|
498
|
-
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
|
|
499
|
-
const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/password`)
|
|
500
|
-
throw err
|
|
501
|
-
}
|
|
502
|
-
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
503
|
-
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
504
|
-
break
|
|
505
|
-
case 'bearer':
|
|
506
|
-
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.token) {
|
|
507
|
-
const err = new Error(`missing configuration entity.${baseEntity}.connection.auth.options.token`)
|
|
508
|
-
throw err
|
|
509
|
-
}
|
|
510
|
-
param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
|
|
511
|
-
break
|
|
512
|
-
case 'oauthSamlBearer':
|
|
513
|
-
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.samlPayload?.companyId
|
|
514
|
-
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
|
|
515
|
-
const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options.tls and/or options.samlPayload.clientId/companyId`)
|
|
516
|
-
throw err
|
|
517
|
-
}
|
|
518
|
-
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
519
|
-
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
520
|
-
break
|
|
521
|
-
case 'oauthJwtBearer':
|
|
522
|
-
// auth.options.azureTenantId => Microsoft Entra ID
|
|
523
|
-
// auth.options.serviceAccountKeyFile => Google Service Account
|
|
524
|
-
// also support custom using tokenUrl/jwtPayload
|
|
525
|
-
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
526
|
-
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
527
|
-
break
|
|
528
|
-
|
|
529
|
-
default:
|
|
530
|
-
// no auth or PassTrough
|
|
515
|
+
param.accessToken = await this.getAccessToken(baseEntity, connectionObj, ctx)
|
|
516
|
+
if (param.accessToken?.access_token && param.accessToken?.token_type) {
|
|
517
|
+
param.options.headers['Authorization'] = `${param.accessToken.token_type} ${param.accessToken.access_token}`
|
|
518
|
+
} else { // no auth or PassTrough
|
|
519
|
+
delete param.accessToken
|
|
531
520
|
}
|
|
532
521
|
|
|
533
522
|
if (orgConnection) {
|
|
534
|
-
|
|
523
|
+
connectionObj = orgConnection // reset back to original
|
|
535
524
|
if (opt?.connection) delete opt.connection
|
|
536
525
|
}
|
|
537
526
|
|
|
538
527
|
// proxy
|
|
539
|
-
if (
|
|
540
|
-
const agent = new HttpsProxyAgent(
|
|
528
|
+
if (connectionObj.proxy?.host) {
|
|
529
|
+
const agent = new HttpsProxyAgent(connectionObj.proxy.host)
|
|
541
530
|
param.options.agent = agent // proxy
|
|
542
|
-
if (
|
|
543
|
-
param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${
|
|
531
|
+
if (connectionObj.proxy.username && connectionObj.proxy.password) {
|
|
532
|
+
param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${connectionObj.proxy.username}:${connectionObj.proxy.password}`).toString('base64') // using proxy with auth
|
|
544
533
|
}
|
|
545
534
|
}
|
|
546
535
|
|
|
547
|
-
if (
|
|
548
|
-
const connOpt: any = utils.copyObj(
|
|
536
|
+
if (connectionObj.options) { // http connect options
|
|
537
|
+
const connOpt: any = utils.copyObj(connectionObj.options)
|
|
549
538
|
try {
|
|
550
539
|
// using fs.readFileSync().toString() instead of Bun.file().text() for nodejs compability
|
|
551
540
|
if (connOpt?.tls?.key) connOpt.tls.key = fs.readFileSync(connOpt.tls.key).toString()
|
|
@@ -615,11 +604,11 @@ export class HelperRest {
|
|
|
615
604
|
}
|
|
616
605
|
|
|
617
606
|
// proxy
|
|
618
|
-
if (
|
|
619
|
-
const agent = new HttpsProxyAgent(
|
|
607
|
+
if (connectionObj.proxy?.host) {
|
|
608
|
+
const agent = new HttpsProxyAgent(connectionObj.proxy.host)
|
|
620
609
|
options.agent = agent // proxy
|
|
621
|
-
if (
|
|
622
|
-
options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${
|
|
610
|
+
if (connectionObj.proxy.username && connectionObj.proxy.password) {
|
|
611
|
+
options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${connectionObj.proxy.username}:${connectionObj.proxy.password}`).toString('base64') // using proxy with auth
|
|
623
612
|
}
|
|
624
613
|
}
|
|
625
614
|
|
|
@@ -653,93 +642,100 @@ export class HelperRest {
|
|
|
653
642
|
* @param baseEntity baseEntity
|
|
654
643
|
* @param method GET, PATCH, PUT, DELETE
|
|
655
644
|
* @param path path e.g., /Users (baseUrls configuration will automatically be included) or use full url e.g., https://my-company.com/Users
|
|
656
|
-
* @param body body
|
|
657
|
-
* @param ctx ctx when using Auth PassThrough
|
|
658
|
-
* @param opt web-standard fetch client options, e.g., options not defined as general options in configuration file
|
|
645
|
+
* @param body optional, body
|
|
646
|
+
* @param ctx coptional, ctx when using Auth PassThrough
|
|
647
|
+
* @param opt optional, web-standard fetch client options, e.g., using custom options not defined as general options in configuration file
|
|
659
648
|
* @param retryCount internal use only - internal counter for retry and failover logic to other baseUrls defined
|
|
660
649
|
**/
|
|
661
650
|
private async doRequestHandler(baseEntity: string, method: string, path: string, body?: any, ctx?: any, opt?: any, retryCount?: number): Promise<any> {
|
|
651
|
+
const connectionObj = this.config_entity[baseEntity]?.connection ?? {}
|
|
662
652
|
let retryAfter = 0
|
|
663
653
|
try {
|
|
664
|
-
const
|
|
654
|
+
const controller = new AbortController()
|
|
655
|
+
const signal = controller.signal
|
|
656
|
+
const cli = await this.getServiceClient(baseEntity, connectionObj, method, path, opt, ctx)
|
|
665
657
|
const options = cli.options
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
658
|
+
const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
|
|
659
|
+
options.signal = signal
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
let dataString = ''
|
|
663
|
+
if (body) {
|
|
664
|
+
if (options.headers['Content-Type']) {
|
|
665
|
+
const type: string = options.headers['Content-Type'].toLowerCase().trim()
|
|
666
|
+
if (type.startsWith('application/x-www-form-urlencoded')) {
|
|
667
|
+
if (typeof body === 'string') dataString = body
|
|
668
|
+
else dataString = querystring.stringify(body) // JSON to query string syntax + URL encoded
|
|
669
|
+
} else {
|
|
670
|
+
if (typeof body === 'string') dataString = body
|
|
671
|
+
else dataString = JSON.stringify(body)
|
|
672
|
+
}
|
|
673
673
|
} else {
|
|
674
|
+
options.headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
674
675
|
if (typeof body === 'string') dataString = body
|
|
675
676
|
else dataString = JSON.stringify(body)
|
|
676
677
|
}
|
|
677
|
-
|
|
678
|
-
options.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const f = await fetch(url, options)
|
|
692
|
-
clearTimeout(timeout)
|
|
693
|
-
if (!f.status) throw new Error('response missing statusCode header')
|
|
694
|
-
const result: any = {
|
|
695
|
-
statusCode: f.status,
|
|
696
|
-
statusMessage: f.statusText,
|
|
697
|
-
body: null,
|
|
698
|
-
}
|
|
699
|
-
const contentType = f.headers.get('content-type')
|
|
700
|
-
if (contentType) {
|
|
701
|
-
if (contentType.includes('json')) result.body = await f.json()
|
|
702
|
-
else {
|
|
703
|
-
result.body = await f.text()
|
|
704
|
-
try {
|
|
705
|
-
result.body = JSON.parse(result)
|
|
706
|
-
} catch (err) { void 0 }
|
|
678
|
+
options.headers['Content-Length'] = Buffer.byteLength(dataString, 'utf8')
|
|
679
|
+
options.body = dataString
|
|
680
|
+
} else if (options.headers) delete options.headers['Content-Type']
|
|
681
|
+
|
|
682
|
+
const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
|
|
683
|
+
|
|
684
|
+
// execute request
|
|
685
|
+
const f = await fetch(url, options)
|
|
686
|
+
if (!f.status) throw new Error('Response missing status code')
|
|
687
|
+
|
|
688
|
+
const result: any = {
|
|
689
|
+
statusCode: f.status,
|
|
690
|
+
statusMessage: f.statusText,
|
|
691
|
+
body: null,
|
|
707
692
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
if (
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
693
|
+
|
|
694
|
+
const contentType = f.headers.get('content-type')
|
|
695
|
+
if (contentType?.includes('json')) {
|
|
696
|
+
result.body = await f.json().catch(() => f.text())
|
|
697
|
+
} else {
|
|
698
|
+
const bodyText = await f.text()
|
|
699
|
+
try { result.body = JSON.parse(bodyText) } catch (err) { result.body = bodyText }
|
|
714
700
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const arr = result['@odata.nextLink'].split('?')[0].split('/')
|
|
722
|
-
const objType = (arr[arr.length - 1]) // users
|
|
723
|
-
let startIndexNext = ''
|
|
724
|
-
if (this._serviceClient[baseEntity].nextLink[objType]) {
|
|
725
|
-
for (const k in this._serviceClient[baseEntity].nextLink[objType]) {
|
|
726
|
-
if (this._serviceClient[baseEntity].nextLink[objType][k] === nextUrl) return result // repetive startIndex=1
|
|
727
|
-
startIndexNext = k
|
|
728
|
-
break
|
|
701
|
+
|
|
702
|
+
if (f.status > 399) {
|
|
703
|
+
if (f.status === 429) { // throttle
|
|
704
|
+
const v = f.headers.get('retry-after')
|
|
705
|
+
if (v) retryAfter = parseInt(v, 10) + 1
|
|
706
|
+
else retryAfter = 10
|
|
729
707
|
}
|
|
708
|
+
throw new Error(JSON.stringify(result))
|
|
730
709
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
710
|
+
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
|
|
711
|
+
if (result.body && typeof result.body === 'object' && result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
|
|
712
|
+
// OData paging
|
|
713
|
+
const nextUrl = result.body['@odata.nextLink'].split('?')[1] // keep search query
|
|
714
|
+
const arr = result['@odata.nextLink'].split('?')[0].split('/')
|
|
715
|
+
const objType = (arr[arr.length - 1]) // users
|
|
716
|
+
let startIndexNext = ''
|
|
717
|
+
if (this._serviceClient[baseEntity].nextLink[objType]) {
|
|
718
|
+
for (const k in this._serviceClient[baseEntity].nextLink[objType]) {
|
|
719
|
+
if (this._serviceClient[baseEntity].nextLink[objType][k] === nextUrl) return result // repetive startIndex=1
|
|
720
|
+
startIndexNext = k
|
|
721
|
+
break
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const a = result.body['@odata.nextLink'].split('top=')
|
|
725
|
+
let top = '0'
|
|
726
|
+
if (a.length > 1) {
|
|
727
|
+
top = a[1].split('&')[0]
|
|
728
|
+
}
|
|
729
|
+
if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
|
|
730
|
+
else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
|
|
731
|
+
// reset and set new nextLink
|
|
732
|
+
this._serviceClient[baseEntity].nextLink[objType] = {}
|
|
733
|
+
this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
|
|
735
734
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
this._serviceClient[baseEntity].nextLink[objType] = {}
|
|
740
|
-
this._serviceClient[baseEntity].nextLink[objType][startIndexNext] = nextUrl
|
|
735
|
+
return result
|
|
736
|
+
} finally {
|
|
737
|
+
clearTimeout(timeout)
|
|
741
738
|
}
|
|
742
|
-
return result
|
|
743
739
|
} catch (err: any) { // includes failover/retry logic based on config baseUrls array
|
|
744
740
|
let statusCode
|
|
745
741
|
try { statusCode = JSON.parse(err.message).statusCode } catch (e) { void 0 }
|
|
@@ -750,7 +746,7 @@ export class HelperRest {
|
|
|
750
746
|
let urlObj
|
|
751
747
|
try { urlObj = new URL(path) } catch (err) { void 0 }
|
|
752
748
|
let isServiceClient = !urlObj && this._serviceClient[baseEntity] && !this.lock.isLocked() // !isLocked to avoid retry ongoing doRequest with failing getAccessToken()
|
|
753
|
-
let oAuthTokeErr = statusCode === 401 &&
|
|
749
|
+
let oAuthTokeErr = statusCode === 401 && connectionObj?.auth?.type && connectionObj.auth.type.startsWith('oauth')
|
|
754
750
|
|
|
755
751
|
if (isServiceClient && (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ABORT_ERR' || err.code === 'ETIMEDOUT' || statusCode === 504 || oAuthTokeErr || retryAfter)) {
|
|
756
752
|
this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${path} Body = ${JSON.stringify(body)} Error Response = ${err.message}`)
|
|
@@ -760,11 +756,11 @@ export class HelperRest {
|
|
|
760
756
|
resolve(null)
|
|
761
757
|
}, retryAfter * 1000))
|
|
762
758
|
}
|
|
763
|
-
if (retryCount <
|
|
759
|
+
if (retryCount < connectionObj.baseUrls.length) {
|
|
764
760
|
retryCount++
|
|
765
761
|
if (isServiceClient) {
|
|
766
|
-
this.updateServiceClient(baseEntity, { baseUrl:
|
|
767
|
-
this.scimgateway.logDebug(baseEntity, `${(
|
|
762
|
+
this.updateServiceClient(baseEntity, { baseUrl: connectionObj.baseUrls[retryCount - 1] })
|
|
763
|
+
this.scimgateway.logDebug(baseEntity, `${(connectionObj.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
|
|
768
764
|
}
|
|
769
765
|
if (oAuthTokeErr) {
|
|
770
766
|
delete this._serviceClient[baseEntity] // ensure new getAccessToken request - token used should not have been expired, but rejected for other reason e.g. token server restart and no persistent token store?
|
package/lib/scimgateway.ts
CHANGED
|
@@ -555,7 +555,7 @@ export class ScimGateway {
|
|
|
555
555
|
|
|
556
556
|
if (ctx.response.status && ctx.response.status > 399) {
|
|
557
557
|
try {
|
|
558
|
-
const o = JSON.parse(ctx.response.body ?? '')
|
|
558
|
+
const o = JSON.parse(ctx.response.body as string ?? '')
|
|
559
559
|
if (o.detail) msg = o.detail
|
|
560
560
|
else if (o.Errors && Array.isArray(o.Errors) && o.Errors[0]?.description) msg = o.Errors[0].description
|
|
561
561
|
} catch (err) { }
|
|
@@ -2161,7 +2161,7 @@ export class ScimGateway {
|
|
|
2161
2161
|
|
|
2162
2162
|
let body: any
|
|
2163
2163
|
if (bulkCtx.response.body) {
|
|
2164
|
-
body = JSON.parse(bulkCtx.response.body)
|
|
2164
|
+
body = JSON.parse(bulkCtx.response.body as string)
|
|
2165
2165
|
if (op.bulkId && body.id) bulkIdMap.set(op.bulkId, body.id)
|
|
2166
2166
|
}
|
|
2167
2167
|
|
|
@@ -2387,6 +2387,10 @@ export class ScimGateway {
|
|
|
2387
2387
|
logger.debug(`${gwName} calling getApi`, { baseEntity: ctx?.routeObj?.baseEntity })
|
|
2388
2388
|
let result = await this.getApi(baseEntity, id, ctx.query, ctx.passThrough)
|
|
2389
2389
|
if (result) {
|
|
2390
|
+
if (result instanceof ReadableStream) { // support long-running tasks
|
|
2391
|
+
ctx.response.body = result
|
|
2392
|
+
return
|
|
2393
|
+
}
|
|
2390
2394
|
if (typeof result === 'string') {
|
|
2391
2395
|
const r = result.trim()
|
|
2392
2396
|
if (r.startsWith('<') && r.endsWith('>')) {
|
|
@@ -2565,7 +2569,7 @@ export class ScimGateway {
|
|
|
2565
2569
|
response: {
|
|
2566
2570
|
headers: Headers // HeadersInit
|
|
2567
2571
|
status?: number
|
|
2568
|
-
body?: string
|
|
2572
|
+
body?: string | ReadableStream<any>
|
|
2569
2573
|
}
|
|
2570
2574
|
routeObj: RouteObj
|
|
2571
2575
|
perfStart: number
|
|
@@ -2838,6 +2842,91 @@ export class ScimGateway {
|
|
|
2838
2842
|
}
|
|
2839
2843
|
|
|
2840
2844
|
const onAfterHandle = async (ctx: Context): Promise<Response> => {
|
|
2845
|
+
if (ctx.response.body instanceof ReadableStream && !ctx.response.headers.get('Content-Type')?.includes('text/event-stream')) {
|
|
2846
|
+
// This handles long-running tasks from plugins that return a ReadableStream.
|
|
2847
|
+
// Currently available by getApiHandler() - GET /api
|
|
2848
|
+
// ReadableStream body gives header "Transfer-Encoding: chunked" keeping connection open until last chunk and stream is closed
|
|
2849
|
+
// In addition implementing heartbeat for preventing proxy/loadbalancer closing connection
|
|
2850
|
+
//
|
|
2851
|
+
// corresponding plugin example code:
|
|
2852
|
+
/*
|
|
2853
|
+
const { readable, writable } = new TransformStream()
|
|
2854
|
+
// process the original stream in the background
|
|
2855
|
+
; (async () => {
|
|
2856
|
+
const writer = writable.getWriter()
|
|
2857
|
+
try {
|
|
2858
|
+
const options = { abortTimeout: 5 * 60 } // 5 minutes
|
|
2859
|
+
const data = await helper.doRequest(,,,,,options)
|
|
2860
|
+
await writer.write(new TextEncoder().encode(data.body ?? ''))
|
|
2861
|
+
} catch (err: any) {
|
|
2862
|
+
await writer.write(new TextEncoder().encode(`error: ${err.message}`))
|
|
2863
|
+
} finally {
|
|
2864
|
+
await writer.close()
|
|
2865
|
+
}
|
|
2866
|
+
})()
|
|
2867
|
+
return readable // return the readable part immediately
|
|
2868
|
+
*/
|
|
2869
|
+
const originalStream = ctx.response.body
|
|
2870
|
+
const originalHeaders = new Headers(ctx.response.headers)
|
|
2871
|
+
let originalStatus = ctx.response.status || 200
|
|
2872
|
+
|
|
2873
|
+
const { readable, writable } = new TransformStream()
|
|
2874
|
+
|
|
2875
|
+
const processStream = async () => {
|
|
2876
|
+
const reader = originalStream.getReader()
|
|
2877
|
+
const writer = writable.getWriter()
|
|
2878
|
+
|
|
2879
|
+
// Heartbeat to keep the connection alive for long-running tasks
|
|
2880
|
+
const heartbeat = setInterval(() => {
|
|
2881
|
+
if (writer.desiredSize && writer.desiredSize > 0) {
|
|
2882
|
+
writer.write(new Uint8Array([32])).catch(() => {}) // space
|
|
2883
|
+
}
|
|
2884
|
+
}, 15000)
|
|
2885
|
+
|
|
2886
|
+
try {
|
|
2887
|
+
const { done, value } = await reader.read()
|
|
2888
|
+
if (!done) {
|
|
2889
|
+
const firstChunkText = new TextDecoder().decode(value).trim()
|
|
2890
|
+
if (firstChunkText.startsWith('<') && firstChunkText.endsWith('>')) {
|
|
2891
|
+
originalHeaders.set('content-type', 'text/html; charset=utf-8')
|
|
2892
|
+
} else if (firstChunkText.startsWith('{') || firstChunkText.startsWith('[')) {
|
|
2893
|
+
originalHeaders.set('content-type', 'application/json; charset=utf-8')
|
|
2894
|
+
} else {
|
|
2895
|
+
originalHeaders.set('content-type', 'text/plain; charset=utf-8')
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
if (firstChunkText.startsWith('error: ')) {
|
|
2899
|
+
originalStatus = 500
|
|
2900
|
+
}
|
|
2901
|
+
ctx.response.body = firstChunkText
|
|
2902
|
+
|
|
2903
|
+
// Write the first chunk and then pipe the rest
|
|
2904
|
+
await writer.write(value)
|
|
2905
|
+
while (true) {
|
|
2906
|
+
const { done, value } = await reader.read()
|
|
2907
|
+
if (done) break
|
|
2908
|
+
await writer.write(value)
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
} catch (err: any) {
|
|
2912
|
+
logger.error(`${gwName} onAfterHandle streaming error: ${err.message}`)
|
|
2913
|
+
await writer.abort(err).catch(() => {})
|
|
2914
|
+
} finally {
|
|
2915
|
+
clearInterval(heartbeat)
|
|
2916
|
+
await writer.close().catch(() => {})
|
|
2917
|
+
reader.releaseLock()
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
processStream()
|
|
2921
|
+
|
|
2922
|
+
const response = new Response(readable, { status: originalStatus, headers: originalHeaders })
|
|
2923
|
+
ctx.response.status = response.status
|
|
2924
|
+
ctx.response.headers = response.headers
|
|
2925
|
+
logResult(ctx)
|
|
2926
|
+
return response
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// default non-streaming responses
|
|
2841
2930
|
if (!ctx.response.status) ctx.response.status = 200
|
|
2842
2931
|
if (ctx.response.status === 401) {
|
|
2843
2932
|
// 401 - do not return scim formatted error message e.g., using PassThrough
|
|
@@ -3346,8 +3435,12 @@ export class ScimGateway {
|
|
|
3346
3435
|
}
|
|
3347
3436
|
|
|
3348
3437
|
process.setMaxListeners(Infinity)
|
|
3349
|
-
process.on('unhandledRejection', (
|
|
3350
|
-
|
|
3438
|
+
process.on('unhandledRejection', (reason: any, _promise: Promise<any>) => { // older versions of V8, unhandled promise rejections are silently dropped
|
|
3439
|
+
if (reason instanceof Error) {
|
|
3440
|
+
logger.error(`${gwName} async function with unhandledRejection: ${reason.stack}`)
|
|
3441
|
+
} else {
|
|
3442
|
+
logger.error(`${gwName} async function with unhandledRejection: ${JSON.stringify(reason)}`)
|
|
3443
|
+
}
|
|
3351
3444
|
})
|
|
3352
3445
|
process.once('SIGTERM', gracefulShutdown) // kill (windows subsystem lacks signaling support for process.kill)
|
|
3353
3446
|
process.once('SIGINT', gracefulShutdown) // Ctrl+C
|
|
@@ -3582,7 +3675,6 @@ export class ScimGateway {
|
|
|
3582
3675
|
**/
|
|
3583
3676
|
async sendMail(msgObj: Record<string, any>, isHtml: boolean = false) {
|
|
3584
3677
|
const gwName = this.gwName
|
|
3585
|
-
const pluginName = this.pluginName
|
|
3586
3678
|
const logger = this.logger
|
|
3587
3679
|
const authType = this.config.scimgateway?.email?.auth?.type ? this.config.scimgateway.email.auth.type.toLowerCase() : ''
|
|
3588
3680
|
|
|
@@ -3683,7 +3775,7 @@ Content-Transfer-Encoding: quoted-printable
|
|
|
3683
3775
|
host: this.config.scimgateway?.email?.auth?.options?.host, // e.g. smtp.office365.com
|
|
3684
3776
|
port: this.config.scimgateway?.email?.auth?.options?.port || 587,
|
|
3685
3777
|
secure: (this.config.scimgateway?.email?.auth?.options?.port === 465), // false on 25/587
|
|
3686
|
-
tls: {
|
|
3778
|
+
tls: { minVersion: 'TLSv1.2' },
|
|
3687
3779
|
proxy: this.config.scimgateway?.email?.proxy,
|
|
3688
3780
|
}
|
|
3689
3781
|
|
package/lib/utils-scim.ts
CHANGED
|
@@ -625,8 +625,13 @@ export function endpointMapper(direction: string, parseObj: any, mapObj: any) {
|
|
|
625
625
|
dotNewObj[arrMapTo[i]] = dotParse[key] // {"active": {"mapTo": "accountEnabled"} => str.replace("accountEnabled", "active")
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
|
-
const
|
|
629
|
-
|
|
628
|
+
const mapTos = mapTo.split(',').map((item: string) => item.trim()) // 'displayName,addresses.work.postalCode'
|
|
629
|
+
for (let i = 0; i < mapTos.length; i++) {
|
|
630
|
+
const arr = mapTos[i].split('.') // addresses.work.postalCode
|
|
631
|
+
if (arr.length > 2 && complexObj[arr[0]]) {
|
|
632
|
+
complexArr.push(arr[0]) // addresses
|
|
633
|
+
}
|
|
634
|
+
}
|
|
630
635
|
}
|
|
631
636
|
break
|
|
632
637
|
|
|
@@ -662,7 +667,6 @@ export function endpointMapper(direction: string, parseObj: any, mapObj: any) {
|
|
|
662
667
|
}
|
|
663
668
|
newObj = tmpObj
|
|
664
669
|
}
|
|
665
|
-
|
|
666
670
|
if (arrUnsupported.length > 0) { // delete from newObj when not included in map
|
|
667
671
|
for (const i in arrUnsupported) {
|
|
668
672
|
const arr = arrUnsupported[i].split('.') // emails.work.type
|
package/package.json
CHANGED