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 CHANGED
@@ -1,18 +1,19 @@
1
1
  # SCIM Gateway
2
2
 
3
- [![Build Status](https://app.travis-ci.com/jelhub/scimgateway.svg?branch=master)](https://app.travis-ci.com/github/jelhub/scimgateway) [![npm Version](https://img.shields.io/npm/v/scimgateway.svg?style=flat-square&label=latest)](https://www.npmjs.com/package/scimgateway)[![npm Downloads](https://img.shields.io/npm/dm/scimgateway.svg?style=flat-square)](https://www.npmjs.com/package/scimgateway) [![chat disqus](https://jelhub.github.io/images/chat.svg)](https://elshaug.xyz/docs/scimgateway#disqus_thread) [![GitHub forks](https://img.shields.io/github/forks/jelhub/scimgateway.svg?style=social&label=Fork)](https://github.com/jelhub/scimgateway)
3
+ [![Build Status](https://app.travis-ci.com/jelhub/scimgateway.svg?branch=master)](https://app.travis-ci.com/github/jelhub/scimgateway) [![npm Version](https://img.shields.io/npm/v/scimgateway.svg?style=flat-square&label=latest)](https://www.npmjs.com/package/scimgateway)[![npm Downloads](https://img.shields.io/npm/dm/scimgateway.svg?style=flat-square)](https://www.npmjs.com/package/scimgateway) [![chat disqus](https://jelhub.github.io/images/chat.svg)](https://elshaug.xyz/docs/scimgateway#disqus_thread) [![GitHub forks](https://img.shields.io/github/forks/jelhub/scimgateway.svg?style=social&label=Fork)](https://github.com/jelhub/scimgateway)
4
4
 
5
- ---
6
- Author: Jarle Elshaug
5
+ ---
6
+ **Author:** Jarle Elshaug
7
7
 
8
- Validated through IdP's:
8
+ **Validated through IdPs:**
9
9
 
10
- - Symantec/Broadcom Identity Manager
11
- - Microsoft Entra ID
12
- - One Identity Manager
13
- - Okta
14
- - Omada
15
- - SailPoint/IdentityNow
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 | Makes the SCIM Gateway a standalone SCIM endpoint using internal [LokiJS](https://github.com/techfort/LokiJS) |
58
- | **MongoDB** | NoSQL Database | Like plugin Loki, but using external MongoDB. Demonstrates multi-tenant or multi-endpoint through `baseEntity`|
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
- => Health check with a "hello" response
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 files created during `postinstall`
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
- **index.ts** defines one or more plugins to be started
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
- **They both must have the same naming prefix**. For Entra ID endpoint we have:
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 (e.g., port, logging, and authentication).
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 (e.g., host, port, credentials).
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** - Gateway will listen on this port number. Clients (e.g. Provisioning Server) will be using this port number for communicating with the gateway
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** - true or false. False means gateway accepts incoming requests from all clients. True means traffic from only localhost (127.0.0.1) is accepted.
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** - baseUrl for chaining anohter gateway, syntax: `http(s)://host:port`. If defined, gateway beave much like a reverse proxy, validating authorization unless PassThrough mode is enabled. See `Configuration notes` for details
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** - default 120, sets the the number of seconds to wait before timing out a connection due to inactivity
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** - "1.1" or "2.0". Default is "2.0".
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** - true or false, default false. Multivalue attributes supporting types e.g. emails, phoneNumbers, ims, photos, addresses, entitlements and x509Certificates (but not roles, groups and members) will be become "type converted objects" when sent to modifyUser and createUser. This for simplicity of checking attributes included and also for the endpointMapper method (used by plugin-ldap and plugin-entra-id), e.g.:
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** - true or false, default false. If set to true, `meta.location` which contains protocol and hostname from request-url, will be excluded from response e.g. `"{...,meta":{"location":"https://my-company.com/<...>"}}`. If using reverse proxy and not including headers `X-Forwarded-Proto` and `X-Forwarded-Host`, originator will be the proxy and we might not want to expose internal protocol and hostname being used by the proxy request.
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** - true or false, default false. If body contains groups and groupMemberOfUser=true, groups attribute will remain at user object (groups are member of user) instead of default user member of groups that will use modifyGroup method for maintaining group members.
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 used in standard SCIM plugins
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 JavaScript coding editor you may use [Visual Studio Code](https://code.visualstudio.com/ "Visual Studio Code")
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. `const plugins = ['mine']');`
1228
- * Start SCIM Gateway and verify using using your own SCIM API requests or your IdP/IGA system.
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
- Now we are ready for custom coding by editing plugin-mine.ts
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
- 4. **createUser** (test provisioning new account)
1237
- 5. **deleteUser** (test provisioning delete account)
1238
- 6. **modifyUser** (test provisioning modify account)
1239
- 7. **Turn on group functionality** - getGroups having logic for returning groups if groups are supported
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
- 12. **createGroup** (test provisioning new group)
1243
- 13. **deleteGroup** (test provisioning delete account)
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
- const ScimGateway: typeof import('scimgateway').ScimGateway = await (async () => {
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
- // start - mandatory plugin initialization
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
-
@@ -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
- let entityFound = false
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
- entityFound = true
43
- if (this.config_entity[baseEntity]?.connection) {
44
- connectionFound = true
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 (this.config_entity[baseEntity]?.connection?.auth?.options?.azureTenantId) { // Entra ID, setting baseUrls to graph
49
- if (!this.config_entity[baseEntity].connection.baseUrls) {
50
- this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
51
- } else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
52
- this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
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 (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) { // Google, setting baseUrls to googleapis
55
- if (!this.config_entity[baseEntity].connection.baseUrls) {
56
- this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
57
- } else if (this.config_entity[baseEntity].connection.baseUrls?.length < 1) {
58
- this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
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 oauth accesstoken object
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] && this._serviceClient[baseEntity].accessToken
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 = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
88
- const azureTenantId = this.config_entity[baseEntity]?.connection?.auth?.options?.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(this.config_entity[baseEntity].connection.baseUrls[0])
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) this.config_entity[baseEntity].connection.auth.options.scope = resource + '/.default' // "https://graph.microsoft.com/.default"
100
- } else tokenUrl = this.config_entity[baseEntity].connection.auth.options.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 (this.config_entity[baseEntity]?.connection?.auth?.type) {
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: this.config_entity[baseEntity].connection.auth.options.clientId,
108
- client_secret: this.config_entity[baseEntity].connection.auth.options.clientSecret,
115
+ client_id: connectionObj.auth.options.clientId,
116
+ client_secret: connectionObj.auth.options.clientSecret,
109
117
  }
110
- if (this.config_entity[baseEntity].connection.auth.options.scope) form.scope = this.config_entity[baseEntity].connection.auth.options.scope // required using Entra ID /oauth2/v2.0/token
111
- if (this.config_entity[baseEntity].connection.auth.options.resource) resource = this.config_entity[baseEntity].connection.auth.options.resource // required using Entra ID /oauth2/token
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 = this.config_entity[baseEntity].connection.auth.options.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: this.config_entity[baseEntity].connection.auth.options.username,
119
- password: this.config_entity[baseEntity].connection.auth.options.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
- tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
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(this.config_entity[baseEntity].connection.auth.options.tls.cert).toString()
127
- const key = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key).toString()
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 = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId
134
- const companyId = this.config_entity[baseEntity].connection.auth.options.samlPayload.companyId
135
- const nameId = this.config_entity[baseEntity].connection.auth.options.samlPayload.nameId
136
- const userIdentifierFormat = this.config_entity[baseEntity].connection.auth.options.samlPayload.userIdentifierFormat || 'userName'
137
- const lifetime = this.config_entity[baseEntity].connection.auth.options.samlPayload.lifetime || 3600
138
- const issuer = this.config_entity[baseEntity].connection.auth.options.samlPayload.clientId || `https://scimgateway.${this.scimgateway.pluginName}.com`
139
- const audience = this.config_entity[baseEntity].connection.auth.options.samlPayload.audience || `scimgateway/${this.scimgateway.pluginName}`
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 (this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer) { // federated credentials
187
+ if (connectionObj.auth?.options?.fedCred?.issuer) { // federated credentials
157
188
  const now = Date.now()
158
189
  const jwtPayload: jose.JWTPayload = {
159
- iss: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer, // entra id federated credentials issuer - scimgateway base URL, e.g. https://scimgateway.my-company.com
160
- sub: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.subject, // entra id application object id - client id
161
- name: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.name, // entra id federated credentials unique name e.g. plugin-entra-id
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: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
188
- client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.subject,
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 = this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer // all baseEntities should use same issuer
238
+ this.scimgateway.jwk.issuer = connectionObj.auth?.options?.fedCred?.issuer // all baseEntities should use same issuer
208
239
  } else { // standard certificate
209
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
210
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
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 = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
213
- let cert = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._cert || ''
214
- let certPem = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._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(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
217
- certPem = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.cert, 'utf-8') || ''
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
- this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
251
+ connectionObj.auth.options.tls._key = privateKey
221
252
  }
222
253
  if (certPem) {
223
254
  cert = createPublicKey(certPem)
224
- this.config_entity[baseEntity].connection.auth.options.tls._cert = cert
225
- this.config_entity[baseEntity].connection.auth.options.tls._certPem = certPem
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 '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert file content`)
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: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
234
- sub: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
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: this.config_entity[baseEntity].connection.auth.options.scope, // "https://graph.microsoft.com/.default"
255
- client_id: this.config_entity[baseEntity]?.connection?.auth?.options?.clientId,
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 (!this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject) {
264
- const err = new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - using auth.options 'serviceAccountKeyFile' requires mandatory configuration entity.${baseEntity}.connection.auth.options.jwtPayload.scope/subject`)
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> = this.config_entity[baseEntity]?.connection?.auth?.options?._gkey
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 '${this.config_entity[baseEntity]?.connection?.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
305
+ throw new Error(`auth type '${connectionObj.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
275
306
  }
276
307
  })()
277
- this.config_entity[baseEntity].connection.auth.options._gkey = gkey
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: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.subject, // gmail sender mail-address: noreply@mycompany.com
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: this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload?.scope, // https://www.googleapis.com/auth/gmail.send
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 (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
308
- || !this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload
309
- || typeof this.config_entity[baseEntity]?.connection?.auth?.options?.jwtPayload !== 'object') {
310
- throw new Error(`auth.type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no azureTenantId/serviceAccountKeyFile using raw) - missing configuration entity.${baseEntity}.connection.auth.options.tokenUrl/jwtPayload`)
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 (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.key) {
313
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' (no azureTenantId/serviceAccountKeyFile using raw) - missing options.tls.key configuration`)
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 = this.config_entity[baseEntity].connection.auth.options.tokenUrl
316
- let privateKey = this.config_entity[baseEntity]?.connection?.auth?.options?.tls?._key || ''
346
+ tokenUrl = connectionObj.auth.options.tokenUrl
347
+ let privateKey = connectionObj.auth?.options?.tls?._key || ''
317
348
  if (!privateKey) {
318
- privateKey = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.tls.key, 'utf-8') || ''
349
+ privateKey = fs.readFileSync(connectionObj.auth.options.tls.key, 'utf-8') || ''
319
350
  if (privateKey) {
320
351
  privateKey = createPrivateKey(privateKey)
321
- this.config_entity[baseEntity].connection.auth.options.tls._key = privateKey
352
+ connectionObj.auth.options.tls._key = privateKey
322
353
  }
323
354
  }
324
355
 
325
- let jwtPayload: jose.JWTPayload = this.config_entity[baseEntity].connection.auth.options.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
- throw new Error(`getAccessToken() none supported entity.${baseEntity}.connection.auth.type: '${this.config_entity[baseEntity]?.connection?.auth?.type}'`)
379
+ // no auth or PassTrough
380
+ return {}
349
381
  }
350
382
 
351
383
  if (!tokenUrl) {
352
- throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing tokenUrl`)
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 (this.config_entity[baseEntity].connection.options && typeof this.config_entity[baseEntity].connection.options === 'object') {
359
- connOpt = utils.copyObj(this.config_entity[baseEntity].connection.options)
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 (this.config_entity[baseEntity]?.connection?.auth?.type === 'token') { // in case response using token instead of access_token
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'] = ` Bearer ${accessToken.access_token}`
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 (!this.config_entity[baseEntity]?.connection?.baseUrls || !Array.isArray(this.config_entity[baseEntity].connection.baseUrls) || this.config_entity[baseEntity].connection.baseUrls.length < 1) {
440
- const err = new Error(`missing configuration entity.${baseEntity}.connection.baseUrls`)
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(this.config_entity[baseEntity].connection.baseUrls[0])
477
+ urlObj = new URL(connectionObj.baseUrls[0])
444
478
  const param: any = {
445
- baseUrl: this.config_entity[baseEntity].connection.baseUrls[0],
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 = this.config_entity[baseEntity]?.connection
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 (this.config_entity[baseEntity]?.connection?.auth?.type == 'oauth') {
471
- if (this.config_entity[baseEntity].connection.auth?.options?.azureTenantId) {
472
- if (this.config_entity[baseEntity].connection.auth.options?.tls?.cert
473
- && this.config_entity[baseEntity].connection.auth.options?.tls?.key
474
- && this.config_entity[baseEntity].connection.auth.options.clientId
475
- ) this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
476
- } else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) {
477
- this.config_entity[baseEntity].connection.auth.type = 'oauthJwtBearer'
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
- switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
482
- case 'basic':
483
- if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
484
- const err = new Error(`auth.type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
485
- throw err
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
- this.config_entity[baseEntity].connection = orgConnection // reset back to original
523
+ connectionObj = orgConnection // reset back to original
535
524
  if (opt?.connection) delete opt.connection
536
525
  }
537
526
 
538
527
  // proxy
539
- if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
540
- const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
528
+ if (connectionObj.proxy?.host) {
529
+ const agent = new HttpsProxyAgent(connectionObj.proxy.host)
541
530
  param.options.agent = agent // proxy
542
- if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
543
- param.options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
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 (this.config_entity[baseEntity]?.connection?.options) { // http connect options
548
- const connOpt: any = utils.copyObj(this.config_entity[baseEntity].connection.options)
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 (this.config_entity[baseEntity]?.connection?.proxy?.host) {
619
- const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
607
+ if (connectionObj.proxy?.host) {
608
+ const agent = new HttpsProxyAgent(connectionObj.proxy.host)
620
609
  options.agent = agent // proxy
621
- if (this.config_entity[baseEntity].connection.proxy.username && this.config_entity[baseEntity].connection.proxy.password) {
622
- options.headers['Proxy-Authorization'] = 'Basic ' + Buffer.from(`${this.config_entity[baseEntity].connection.proxy.username}:${this.config_entity[baseEntity].connection.proxy.password}`).toString('base64') // using proxy with auth
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 cli = await this.getServiceClient(baseEntity, method, path, opt, ctx)
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
- let dataString = ''
667
- if (body) {
668
- if (options.headers['Content-Type']) {
669
- const type: string = options.headers['Content-Type'].toLowerCase().trim()
670
- if (type.startsWith('application/x-www-form-urlencoded')) {
671
- if (typeof body === 'string') dataString = body
672
- else dataString = querystring.stringify(body) // JSON to query string syntax + URL encoded
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
- } else {
678
- options.headers['Content-Type'] = 'application/json; charset=utf-8'
679
- if (typeof body === 'string') dataString = body
680
- else dataString = JSON.stringify(body)
681
- }
682
- options.headers['Content-Length'] = Buffer.byteLength(dataString, 'utf8')
683
- options.body = dataString
684
- } else if (options.headers) delete options.headers['Content-Type']
685
- const controller = new AbortController()
686
- const signal = controller.signal
687
- const timeout = setTimeout(() => controller.abort(), options.abortTimeout ? options.abortTimeout * 1000 : this.idleTimeout * 1000) // 120 seconds default abort timeout
688
- options.signal = signal
689
- const url = `${options.protocol}//${options.host}${options.port ? ':' + options.port : ''}${options.path}`
690
- // execute request
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
- if (f.status > 399) {
710
- if (f.status === 429) { // throttle
711
- const v = f.headers.get('retry-after')
712
- if (v) retryAfter = parseInt(v, 10) + 1
713
- else retryAfter = 10
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
- throw new Error(JSON.stringify(result))
716
- }
717
- this.scimgateway.logDebug(baseEntity, `doRequest ${method} ${options.protocol}//${options.host}${(options.port ? `:${options.port}` : '')}${options.path} Body = ${JSON.stringify(body)} Response = ${JSON.stringify(result)}`)
718
- if (result.body && typeof result.body === 'object' && result.body['@odata.nextLink']) { // {"@odata.nextLink": "https://graph.microsoft.com/beta/users?$top=100&$skiptoken=xxx"}
719
- // OData paging
720
- const nextUrl = result.body['@odata.nextLink'].split('?')[1] // keep search query
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
- const a = result.body['@odata.nextLink'].split('top=')
732
- let top = '0'
733
- if (a.length > 1) {
734
- top = a[1].split('&')[0]
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
- if (!startIndexNext) startIndexNext = (Number(top) + 1).toString()
737
- else startIndexNext = (Number(startIndexNext) + Number(top) + 1).toString()
738
- // reset and set new nextLink
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 && this.config_entity[baseEntity].connection?.auth?.type && this.config_entity[baseEntity].connection.auth.type.startsWith('oauth')
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 < this.config_entity[baseEntity].connection.baseUrls.length) {
759
+ if (retryCount < connectionObj.baseUrls.length) {
764
760
  retryCount++
765
761
  if (isServiceClient) {
766
- this.updateServiceClient(baseEntity, { baseUrl: this.config_entity[baseEntity].connection.baseUrls[retryCount - 1] })
767
- this.scimgateway.logDebug(baseEntity, `${(this.config_entity[baseEntity].connection.baseUrls.length > 1) ? 'failover ' : ''}retry[${retryCount}] using baseUrl = ${this._serviceClient[baseEntity].baseUrl}`)
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?
@@ -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', (err: { [key: string]: any }) => { // older versions of V8, unhandled promise rejections are silently dropped
3350
- logger.error(`${gwName} async function with unhandledRejection: ${err.stack}`)
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: { ciphers: 'TLSv1.2' },
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 arr = mapTo.split('.') // addresses.work.postalCode
629
- if (arr.length > 2 && complexObj[arr[0]]) complexArr.push(arr[0]) // addresses
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "6.1.1",
3
+ "version": "6.1.2",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",