scimgateway 5.0.13 → 5.0.15
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 +73 -33
- package/lib/helper-rest.ts +130 -15
- package/lib/logger.ts +1 -1
- package/lib/scimgateway.ts +80 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Validated through IdP's:
|
|
|
16
16
|
|
|
17
17
|
Latest news:
|
|
18
18
|
|
|
19
|
+
- Email, onError and sendMail() supports more secure RESTful OAuth for Microsoft Exchange Online (ExO) and Google Workspace Gmail, alongside traditional SMTP Auth for all mail systems. HelperRest supports a wide range of common authentication methods, including basicAuth, bearerAuth, tokenAuth, oauth, oauthSamlBearer, oauthJwtBearer and Auth PassTrough
|
|
19
20
|
- Major version **v5.0.0** marks a shift to native TypeScript support and prioritizes [Bun](https://bun.sh/) over Node.js. This upgrade requires some modifications to existing plugins.
|
|
20
21
|
- **BREAKING**: [SCIM Stream](https://elshaug.xyz/docs/scim-stream) is the modern way of user provisioning letting clients subscribe to messages instead of traditional IGA top-down provisioning. SCIM Gateway now offers enhanced functionality with support for message subscription and automated provisioning using SCIM Stream
|
|
21
22
|
- Authentication PassThrough letting plugin pass authentication directly to endpoint for avoid maintaining secrets at the gateway. E.g., using Entra ID application OAuth
|
|
@@ -436,18 +437,18 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
|
|
|
436
437
|
"2603:1056:2000::/48",
|
|
437
438
|
"2603:1057:2::/48"
|
|
438
439
|
]
|
|
439
|
-
- **email** -
|
|
440
|
-
- **email.host** - Mailserver e.g. "smtp.gmail.com" - mandatory when not using tenantIdGUID (Microsoft)
|
|
441
|
-
- **email.port** - Port used by mailserver e.g. 587, 25 or 465 - mandatory when not using tenantIdGUID (Microsoft)
|
|
440
|
+
- **email** - Sending email from plugin or automated error notifications emailOnError. For emailOnError only the first error will be sent until sendInterval have passed. Supporting both SMTP Auth and modern REST OAuth. For OAuth, currently Microsoft Exchange Online (ExO) and Google Workspace Gmail are supported - see configuration notes
|
|
442
441
|
- **email.auth** - Authentication configuration
|
|
443
|
-
- **email.auth.type** - `
|
|
444
|
-
- **email.auth.options** - Authentication
|
|
445
|
-
- **email.auth.options.
|
|
446
|
-
- **email.auth.options.
|
|
447
|
-
- **email.auth.options.
|
|
448
|
-
- **email.auth.options.
|
|
449
|
-
- **email.auth.options.
|
|
450
|
-
- **email.auth.options.
|
|
442
|
+
- **email.auth.type** - `oauth` or `smtp`
|
|
443
|
+
- **email.auth.options** - Authentication options - note, different options for type oauth and smtp
|
|
444
|
+
- **email.auth.options.tenantIdGUID (oauth/ExO)** - Entra ID tenant id, mandatory/recommended when using Microsoft Exchange Online
|
|
445
|
+
- **email.auth.options.clientId (oauth/ExO)** - Client ID
|
|
446
|
+
- **email.auth.options.clientSecret (oauth/ExO)** - Client Secret
|
|
447
|
+
- **email.auth.options.serviceAccountKeyFile (oauth/Gmail)** - Google Service Account key json-file name located in the `package-root>\config\certs` directory unless absolute path being defined
|
|
448
|
+
- **email.auth.options.host (smtp)** - Mailserver e.g. "smtp.gmail.com" - mandatory for smtp
|
|
449
|
+
- **email.auth.options.port (smtp)** - Port used by mailserver e.g. 587, 25 or 465 - mandatory for smtp
|
|
450
|
+
- **email.auth.options.username (smtp)** - Mail account for authentication normally same as sender of the email, e.g. "user@gmail.com"
|
|
451
|
+
- **email.auth.options.password (smtp)** - Mail account password
|
|
451
452
|
- **email.proxy** - Proxy configuration if using mailproxy
|
|
452
453
|
- **email.proxy.host** - Proxy host e.g. `http://proxy-host:1234`
|
|
453
454
|
- **email.proxy.username** - username if authentication is required
|
|
@@ -455,29 +456,14 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
|
|
|
455
456
|
- **email.emailOnError** - Contains configuration for sending error notifications by email. Note, only the first error will be sent until sendInterval have passed
|
|
456
457
|
- **email.emailOnError.enabled** - true or false, value set to true will enable email notifications
|
|
457
458
|
- **email.emailOnError.sendInterval** - Default 15. Mail notifications on error are deferred until sendInterval **minutes** have passed since the last notification.
|
|
458
|
-
- **email.emailOnError.from** - Sender email addresses e.g: "noreply@example.com"
|
|
459
|
+
- **email.emailOnError.from** - Sender email addresses e.g: "noreply@example.com". **Mandatory for oauth**. For smtp email.auth.options.username will be used
|
|
459
460
|
- **email.emailOnError.to** - Comma separated list of recipients email addresses e.g: "someone@example.com"
|
|
460
461
|
- **email.emailOnError.cc** - Optional comma separated list of cc mail addresses
|
|
461
462
|
- **email.emailOnError.subject** - Optional mail subject, default `SCIM Gateway error message`
|
|
462
463
|
|
|
463
|
-
Configuration notes when using default configuration oauth and tenantIdGUID - Microsoft Exchange Online (ExO):
|
|
464
|
-
|
|
465
|
-
- Entra ID application must have application permissions `Mail.Send`
|
|
466
|
-
- To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
|
|
467
|
-
|
|
468
|
-
First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
|
|
469
|
-
Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
|
|
470
|
-
|
|
471
|
-
##Connect to Exchange
|
|
472
|
-
Install-Module -Name ExchangeOnlineManagement
|
|
473
|
-
Connect-ExchangeOnline
|
|
474
|
-
|
|
475
|
-
##Create ApplicationAccessPolicy
|
|
476
|
-
New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
|
|
477
|
-
|
|
478
464
|
- **stream** - See [SCIM Stream](https://elshaug.xyz/docs/scim-stream) for configuration details
|
|
479
465
|
|
|
480
|
-
- **endpoint** - Contains endpoint specific configuration according to
|
|
466
|
+
- **endpoint** - Contains endpoint specific configuration according to customized **plugin code**.
|
|
481
467
|
|
|
482
468
|
#### Configuration notes
|
|
483
469
|
|
|
@@ -536,6 +522,45 @@ Definitions in `endpoint` object are customized according to our plugin code. Pl
|
|
|
536
522
|
}
|
|
537
523
|
|
|
538
524
|
|
|
525
|
+
- Email, using Microsoft Exchange Online (ExO)
|
|
526
|
+
|
|
527
|
+
- Entra ID application must have application permissions `Mail.Send`
|
|
528
|
+
- To prevent the sending of emails from any defined mailboxes, an ExO `ApplicationAccessPolicy` must be defined through PowerShell.
|
|
529
|
+
|
|
530
|
+
First create a mail-enabled security-group that only includes those users (mailboxes) the application is allowed to send from
|
|
531
|
+
Note, `mail enabled security group` cannot be created from portal, only from admin or admin.exchange console
|
|
532
|
+
|
|
533
|
+
##Connect to Exchange
|
|
534
|
+
Install-Module -Name ExchangeOnlineManagement
|
|
535
|
+
Connect-ExchangeOnline
|
|
536
|
+
|
|
537
|
+
##Create ApplicationAccessPolicy
|
|
538
|
+
New-ApplicationAccessPolicy -AppId <AppClientID> -PolicyScopeGroupId <MailEnabledSecurityGrpId> -AccessRight RestrictAccess -Description "Restrict app to specific mailboxes"
|
|
539
|
+
|
|
540
|
+
- Email, using Google Workspace Gmail
|
|
541
|
+
|
|
542
|
+
- https://console.cloud.google.com
|
|
543
|
+
- IAM & Admin > Service Accounts > Create Service Account
|
|
544
|
+
- Name=email-sender
|
|
545
|
+
- Create and Continue
|
|
546
|
+
- Grant this service account access to project - not needed
|
|
547
|
+
- Grant users access to this service - not needed
|
|
548
|
+
- IAM & Admin > Service Accounts > "email-sender" account > Keys
|
|
549
|
+
- Add Key > Create new key > JSON
|
|
550
|
+
- download json Service Account Key file, refere to configuration `email.auth.options.serviceAccountKeyFile`
|
|
551
|
+
|
|
552
|
+
- https://admin.google.com
|
|
553
|
+
- Security > Access and data control > API controls
|
|
554
|
+
- Manage Domain Wide Delegation > Add new
|
|
555
|
+
- Client ID = id of service account created
|
|
556
|
+
- OAuth scope = `https://www.googleapis.com/auth/gmail.send`
|
|
557
|
+
|
|
558
|
+
- https://admin.google.com
|
|
559
|
+
- Billing > Subscriptions - verify Google Workspace license
|
|
560
|
+
- Directory > Users > "user"
|
|
561
|
+
- Licenses > Edit > enable Google Workspace license
|
|
562
|
+
`email.emailOnError.from` mail address must have Google Workspace license
|
|
563
|
+
|
|
539
564
|
|
|
540
565
|
## Manual startup
|
|
541
566
|
|
|
@@ -984,17 +1009,17 @@ For JavaScript coding editor you may use [Visual Studio Code](https://code.visua
|
|
|
984
1009
|
|
|
985
1010
|
Preparation:
|
|
986
1011
|
|
|
987
|
-
* 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
|
|
1012
|
+
* 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
|
|
988
1013
|
* Edit plugin-mine.json and define a unique port number for the gateway setting
|
|
989
1014
|
* Edit index.ts and include your plugin in the startup e.g. `const plugins = ['mine']');`
|
|
990
|
-
* Start SCIM Gateway and verify
|
|
1015
|
+
* Start SCIM Gateway and verify using using your own SCIM API requests or your IdP/IGA system.
|
|
991
1016
|
|
|
992
1017
|
Now we are ready for custom coding by editing plugin-mine.ts
|
|
993
|
-
Coding should be done step by step and each step should be verified and tested before starting the next
|
|
1018
|
+
Coding should be done step by step and each step should be verified and tested before starting the next
|
|
994
1019
|
|
|
995
|
-
1. **Turn off group functionality** - getGroups to return empty response
|
|
1020
|
+
1. **Turn off group functionality** - getGroups to return empty response (gateway automatically use getGroups for some of the methods if groups not included)
|
|
996
1021
|
Please see plugin-saphana that do not use groups.
|
|
997
|
-
2. **getUsers** (test provisioning retrieve accounts)
|
|
1022
|
+
2. **getUsers** (test provisioning retrieve all accounts and single account)
|
|
998
1023
|
4. **createUser** (test provisioning new account)
|
|
999
1024
|
5. **deleteUser** (test provisioning delete account)
|
|
1000
1025
|
6. **modifyUser** (test provisioning modify account)
|
|
@@ -1111,6 +1136,21 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
|
|
|
1111
1136
|
|
|
1112
1137
|
## Change log
|
|
1113
1138
|
|
|
1139
|
+
### v5.0.15
|
|
1140
|
+
|
|
1141
|
+
[Improved]
|
|
1142
|
+
|
|
1143
|
+
- HelperRest, auth.type=oauthSamlAssertion and auth.type=oauthJwtAssertion have been updated to `oauthSamlBearer` and `oauthJwtBearer` for consistency
|
|
1144
|
+
|
|
1145
|
+
### v5.0.14
|
|
1146
|
+
|
|
1147
|
+
[Improved]
|
|
1148
|
+
|
|
1149
|
+
- email now supports Google Workspace Gmail using REST OAuth
|
|
1150
|
+
- email workaround for ExO national characters introduced in v5.0.7 not needed anymore - ExO/GraphApi seems to have been fixed
|
|
1151
|
+
- some minor cosmetics on email message layout formatting when using plain text message
|
|
1152
|
+
- HelperRest now includes authentication type `oauthJwtAssertion`
|
|
1153
|
+
|
|
1114
1154
|
### v5.0.13
|
|
1115
1155
|
|
|
1116
1156
|
[Improved]
|
package/lib/helper-rest.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent'
|
|
|
2
2
|
import { URL } from 'url'
|
|
3
3
|
import { Buffer } from 'node:buffer'
|
|
4
4
|
import { samlAssertion } from './samlAssertion.ts' // prereq: saml
|
|
5
|
+
import { sign as jwtSign } from 'jsonwebtoken'
|
|
5
6
|
import fs from 'node:fs'
|
|
6
7
|
import querystring from 'querystring'
|
|
7
8
|
import * as utils from './utils.ts'
|
|
@@ -16,6 +17,7 @@ export class HelperRest {
|
|
|
16
17
|
private scimgateway: any
|
|
17
18
|
private idleTimeout: number
|
|
18
19
|
private graphUrl = 'https://graph.microsoft.com/beta' // beta instead of 'v1.0' gives all user attributes when no $select
|
|
20
|
+
private googleUrl = 'https://www.googleapis.com'
|
|
19
21
|
|
|
20
22
|
constructor(scimgateway: any, optionalEntities?: Record<string, any>) {
|
|
21
23
|
if (!scimgateway || !scimgateway.gwName) throw new Error('HelperRest initialization error: argument scimgateway is not of type ScimGateway')
|
|
@@ -33,6 +35,11 @@ export class HelperRest {
|
|
|
33
35
|
if (this.config_entity[baseEntity]?.connection?.auth?.type === 'oauth') {
|
|
34
36
|
this.config_entity[baseEntity].connection.baseUrls = [this.graphUrl]
|
|
35
37
|
}
|
|
38
|
+
} else if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) { // Google, setting baseUrls to googleapis
|
|
39
|
+
const type = this.config_entity[baseEntity]?.connection?.auth?.type
|
|
40
|
+
if (type === 'oauthJwtBearer' || type === 'oauth') { // includes oauth because of email.auth.type
|
|
41
|
+
this.config_entity[baseEntity].connection.baseUrls = [this.googleUrl]
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
connectionFound = true
|
|
38
45
|
}
|
|
@@ -92,7 +99,7 @@ export class HelperRest {
|
|
|
92
99
|
}
|
|
93
100
|
break
|
|
94
101
|
|
|
95
|
-
case '
|
|
102
|
+
case 'oauthSamlBearer':
|
|
96
103
|
tokenUrl = this.config_entity[baseEntity].connection.auth.options.tokenUrl
|
|
97
104
|
const context = null
|
|
98
105
|
const cert = fs.readFileSync(this.config_entity[baseEntity].connection.auth.options.certificate.cert).toString()
|
|
@@ -107,13 +114,57 @@ export class HelperRest {
|
|
|
107
114
|
const audience = `scimgateway/${this.scimgateway.pluginName}`
|
|
108
115
|
const delay = 1
|
|
109
116
|
|
|
110
|
-
const assertion = await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay)
|
|
111
117
|
form = {
|
|
112
118
|
token_url: tokenUrl,
|
|
113
119
|
grant_type: 'urn:ietf:params:oauth:grant-type:saml2-bearer',
|
|
114
120
|
client_id: clientId,
|
|
115
121
|
company_id: this.config_entity[baseEntity].connection.auth.options.companyId,
|
|
116
|
-
assertion:
|
|
122
|
+
assertion: await samlAssertion.run(context, cert, key, issuer, lifetime, clientId, nameId, userIdentifierFormat, tokenEndpoint, audience, delay),
|
|
123
|
+
}
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
case 'oauthJwtBearer':
|
|
127
|
+
let privateKey = ''
|
|
128
|
+
let jwtAttr: Record<string, any> = {}
|
|
129
|
+
const serviceAccountKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile
|
|
130
|
+
|
|
131
|
+
if (serviceAccountKeyFile) { // Google Service Account key json-file
|
|
132
|
+
const gkey: Record<string, any> = await (async () => {
|
|
133
|
+
try {
|
|
134
|
+
const jsonObject = await import(serviceAccountKeyFile, { assert: { type: 'json' } })
|
|
135
|
+
return jsonObject.default // access the object via the `default` property
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - serviceAccountKeyFile error: ${err.message}`)
|
|
138
|
+
}
|
|
139
|
+
})()
|
|
140
|
+
|
|
141
|
+
tokenUrl = gkey.token_uri // https://oauth2.googleapis.com/token
|
|
142
|
+
privateKey = gkey.private_key
|
|
143
|
+
jwtAttr = {
|
|
144
|
+
scope: this.config_entity[baseEntity]?.connection?.auth?.options?.scope, // https://www.googleapis.com/auth/gmail.send
|
|
145
|
+
sub: this.config_entity[baseEntity]?.connection?.auth?.options?.subject, // firstname.lastname@mycompany.com
|
|
146
|
+
iss: gkey.client_email, // service account email/user
|
|
147
|
+
aud: gkey.token_uri,
|
|
148
|
+
iat: Math.floor(Date.now() / 1000), // issued at
|
|
149
|
+
exp: Math.floor(Date.now() / 1000) + (60 * 60), // expiration time
|
|
150
|
+
}
|
|
151
|
+
} else { // standard JWT requires all configuation set
|
|
152
|
+
tokenUrl = this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
|
|
153
|
+
const privateKeyFile = this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key
|
|
154
|
+
if (privateKeyFile) privateKey = fs.readFileSync(privateKeyFile).toString()
|
|
155
|
+
jwtAttr = {
|
|
156
|
+
scope: this.config_entity[baseEntity]?.connection?.auth?.options?.scope,
|
|
157
|
+
sub: this.config_entity[baseEntity]?.connection?.auth?.options?.subject,
|
|
158
|
+
iss: this.config_entity[baseEntity]?.connection?.auth?.options?.issuer,
|
|
159
|
+
aud: this.config_entity[baseEntity]?.connection?.auth?.options?.audience,
|
|
160
|
+
iat: Math.floor(Date.now() / 1000),
|
|
161
|
+
exp: Math.floor(Date.now() / 1000) + (60 * 60),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
form = {
|
|
166
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
167
|
+
assertion: jwtSign(jwtAttr, privateKey, { algorithm: 'RS256' }),
|
|
117
168
|
}
|
|
118
169
|
break
|
|
119
170
|
|
|
@@ -232,19 +283,28 @@ export class HelperRest {
|
|
|
232
283
|
},
|
|
233
284
|
}
|
|
234
285
|
|
|
235
|
-
//
|
|
236
|
-
// basicAuth, bearerAuth, oauth, tokenAuth,
|
|
286
|
+
// Support no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
|
|
287
|
+
// basicAuth, bearerAuth, oauth, tokenAuth, oauthSamlBearer, oauthJwtBearer and auth PassTrough using request header authorization
|
|
288
|
+
|
|
289
|
+
let orgConnection: any
|
|
290
|
+
if (opt?.connection) { // allow overriding/extending configuration connection by caller argument opt.connection
|
|
291
|
+
let org = this.config_entity[baseEntity]?.connection
|
|
292
|
+
orgConnection = utils.copyObj(org)
|
|
293
|
+
if (!org) org = {}
|
|
294
|
+
org = utils.extendObj(org, opt.connection)
|
|
295
|
+
}
|
|
296
|
+
|
|
237
297
|
switch (this.config_entity[baseEntity]?.connection?.auth?.type) {
|
|
238
298
|
case 'basic':
|
|
239
299
|
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.username || !this.config_entity[baseEntity]?.connection?.auth?.options?.password) {
|
|
240
|
-
const err = new Error(`auth
|
|
300
|
+
const err = new Error(`auth.type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
|
|
241
301
|
throw err
|
|
242
302
|
}
|
|
243
303
|
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')
|
|
244
304
|
break
|
|
245
305
|
case 'oauth':
|
|
246
306
|
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.clientSecret) {
|
|
247
|
-
const err = new Error(`auth
|
|
307
|
+
const err = new Error(`auth.type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
|
|
248
308
|
throw err
|
|
249
309
|
}
|
|
250
310
|
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
@@ -265,19 +325,45 @@ export class HelperRest {
|
|
|
265
325
|
}
|
|
266
326
|
param.options.headers['Authorization'] = 'Bearer ' + Buffer.from(this.config_entity[baseEntity].connection.auth.options.token).toString('base64')
|
|
267
327
|
break
|
|
268
|
-
case '
|
|
328
|
+
case 'oauthSamlBearer':
|
|
269
329
|
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.clientId || !this.config_entity[baseEntity]?.connection?.auth?.options?.companyId
|
|
270
330
|
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key) {
|
|
271
|
-
const err = new Error(`auth
|
|
331
|
+
const err = new Error(`auth.type 'oauthSamlBearer' - missing configuration entity.${baseEntity}.connection.auth.options...`)
|
|
332
|
+
throw err
|
|
333
|
+
}
|
|
334
|
+
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
335
|
+
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
336
|
+
break
|
|
337
|
+
case 'oauthJwtBearer':
|
|
338
|
+
if (this.config_entity[baseEntity]?.connection?.auth?.options?.serviceAccountKeyFile) { // Google Service Account
|
|
339
|
+
if (!this.config_entity[baseEntity]?.connection?.auth?.options?.scope || !this.config_entity[baseEntity]?.connection?.auth?.options?.subject) {
|
|
340
|
+
const err = new Error(`auth.type 'oauthJwtBearer' - using auth.options 'serviceAccountKeyFile' also requires mandatory configuration entity.${baseEntity}.connection.auth.options.scope/subject`)
|
|
341
|
+
throw err
|
|
342
|
+
}
|
|
343
|
+
} else if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tokenUrl
|
|
344
|
+
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.scope
|
|
345
|
+
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.subject
|
|
346
|
+
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.issuer
|
|
347
|
+
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.audience
|
|
348
|
+
|| !this.config_entity[baseEntity]?.connection?.auth?.options?.certificate?.key
|
|
349
|
+
) {
|
|
350
|
+
const err = new Error(`auth.type 'oauthJwtBearer' - when not using auth.options 'serviceAccountKeyFile' which is related to Google, following auth.options is mandatory: tokenUrl, scope, subject, issuer, audience, certificate.key`)
|
|
272
351
|
throw err
|
|
273
352
|
}
|
|
353
|
+
|
|
274
354
|
param.accessToken = await this.getAccessToken(baseEntity, ctx)
|
|
275
355
|
param.options.headers['Authorization'] = `Bearer ${param.accessToken.access_token}`
|
|
276
356
|
break
|
|
357
|
+
|
|
277
358
|
default:
|
|
278
359
|
// no auth or PassTrough
|
|
279
360
|
}
|
|
280
361
|
|
|
362
|
+
if (orgConnection) {
|
|
363
|
+
this.config_entity[baseEntity].connection = orgConnection // reset back to original
|
|
364
|
+
if (opt?.connection) delete opt.connection
|
|
365
|
+
}
|
|
366
|
+
|
|
281
367
|
// proxy
|
|
282
368
|
if (this.config_entity[baseEntity]?.connection?.proxy?.host) {
|
|
283
369
|
const agent = new HttpsProxyAgent(this.config_entity[baseEntity].connection.proxy.host)
|
|
@@ -334,7 +420,10 @@ export class HelperRest {
|
|
|
334
420
|
// adding none static
|
|
335
421
|
cli.options.method = method
|
|
336
422
|
cli.options.path = `${urlObj.pathname}${urlObj.search}`
|
|
337
|
-
if (opt)
|
|
423
|
+
if (opt) {
|
|
424
|
+
if (opt?.connection) delete opt.connection // only used for internal connection options
|
|
425
|
+
cli.options = utils.extendObj(cli.options, opt) // merge with argument options
|
|
426
|
+
}
|
|
338
427
|
|
|
339
428
|
return cli // final client
|
|
340
429
|
}
|
|
@@ -564,7 +653,7 @@ export class HelperRest {
|
|
|
564
653
|
* ```
|
|
565
654
|
* type defines authentication being used
|
|
566
655
|
* if type not defined, no authentication used
|
|
567
|
-
* valid type is: `basic`, `oauth`, `token`, `bearer` or `
|
|
656
|
+
* valid type is: `basic`, `oauth`, `token`, `bearer` or `oauthSamlBearer`
|
|
568
657
|
*
|
|
569
658
|
* for each valid type there are different auth.options
|
|
570
659
|
*
|
|
@@ -582,9 +671,9 @@ export class HelperRest {
|
|
|
582
671
|
* ```
|
|
583
672
|
* {
|
|
584
673
|
* "options": {
|
|
585
|
-
* "tenantIdGUID": "<Entra ID tenantIdGUID", //
|
|
586
|
-
* "tokenUrl": "<tokenUrl>", // not used when tenantIdGUID defined
|
|
587
|
-
* "clientId": "<clientId",
|
|
674
|
+
* "tenantIdGUID": "<Entra ID tenantIdGUID", // Microsoft Graph API - baseUrls automatically set to [https://graph.microsoft.com/beta]
|
|
675
|
+
* "tokenUrl": "<tokenUrl>", // not used when tenantIdGUID defined - baseUrls required
|
|
676
|
+
* "clientId": "<clientId>",
|
|
588
677
|
* "clientSecret": "<clientSecret>"
|
|
589
678
|
* }
|
|
590
679
|
* }
|
|
@@ -610,7 +699,7 @@ export class HelperRest {
|
|
|
610
699
|
* }
|
|
611
700
|
* ```
|
|
612
701
|
*
|
|
613
|
-
* type=**"
|
|
702
|
+
* type=**"oauthSamlBearer"** having auth.options:
|
|
614
703
|
* ```
|
|
615
704
|
* {
|
|
616
705
|
* "options": {
|
|
@@ -626,6 +715,32 @@ export class HelperRest {
|
|
|
626
715
|
* }
|
|
627
716
|
* ```
|
|
628
717
|
*
|
|
718
|
+
* type=**"oauthJwtBearer"** having auth.options:
|
|
719
|
+
* ```
|
|
720
|
+
* // Google API - baseUrls automatically set to [https://www.googleapis.com]
|
|
721
|
+
* {
|
|
722
|
+
* "options": {
|
|
723
|
+
* "serviceAccountKeyFile": "<Google Service Account key file name>", // located in ./config/certs
|
|
724
|
+
* "scope": "<jwt-scope>"
|
|
725
|
+
* "subject": "<jwt-subject>
|
|
726
|
+
* }
|
|
727
|
+
* }
|
|
728
|
+
*
|
|
729
|
+
* // General JWT API - baseUrls required
|
|
730
|
+
* {
|
|
731
|
+
* "options": {
|
|
732
|
+
* "tokenUrl": "<tokenUrl",
|
|
733
|
+
* "scope": "<jwt-scope>",
|
|
734
|
+
* "subject": "<jwt-subject>",
|
|
735
|
+
* "issuer": "<jwt-issuer>",
|
|
736
|
+
* "audience": "<jwt-audience>",
|
|
737
|
+
* "certificate": {
|
|
738
|
+
* "key": "<signing-key-file-name>"
|
|
739
|
+
* }
|
|
740
|
+
* }
|
|
741
|
+
* }
|
|
742
|
+
* ```
|
|
743
|
+
*
|
|
629
744
|
* **connection.options** can be set according to web-standard fetch client options
|
|
630
745
|
* examples:
|
|
631
746
|
* ```
|
package/lib/logger.ts
CHANGED
package/lib/scimgateway.ts
CHANGED
|
@@ -408,41 +408,43 @@ export class ScimGateway {
|
|
|
408
408
|
if (!this.config.scimgateway.email.auth) this.config.scimgateway.email.auth = {}
|
|
409
409
|
if (!this.config.scimgateway.email.auth.options) this.config.scimgateway.email.auth.options = {}
|
|
410
410
|
if (!this.config.scimgateway.email.emailOnError) this.config.scimgateway.email.emailOnError = {}
|
|
411
|
+
if (!this.config.scimgateway.email.emailOnError) this.config.scimgateway.email.proxy = {}
|
|
411
412
|
|
|
412
413
|
if (!this.config.scimgateway.stream) this.config.scimgateway.stream = {}
|
|
413
414
|
if (!this.config.scimgateway.stream.subscriber) this.config.scimgateway.stream.subscriber = {}
|
|
414
415
|
if (!this.config.scimgateway.stream.publisher) this.config.scimgateway.stream.publisher = {}
|
|
415
416
|
|
|
416
417
|
// start - legacy support
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
if (this.config.scimgateway.emailOnError.smtp.host) {
|
|
420
|
-
this.config.scimgateway.email.host = this.config.scimgateway.emailOnError.smtp.host
|
|
418
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.host) {
|
|
419
|
+
this.config.scimgateway.email.auth.options.host = this.config.scimgateway.emailOnError.smtp.host
|
|
421
420
|
}
|
|
422
|
-
if (this.config.scimgateway
|
|
423
|
-
this.config.scimgateway.email.port = this.config.scimgateway.emailOnError.smtp.port
|
|
421
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.port) {
|
|
422
|
+
this.config.scimgateway.email.auth.options.port = this.config.scimgateway.emailOnError.smtp.port
|
|
424
423
|
}
|
|
425
|
-
if (this.config.scimgateway
|
|
424
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.proxy) {
|
|
426
425
|
this.config.scimgateway.email.proxy = this.config.scimgateway.emailOnError.smtp.proxy
|
|
427
426
|
}
|
|
428
|
-
if (this.config.scimgateway
|
|
427
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.username) {
|
|
429
428
|
this.config.scimgateway.email.emailOnError.from = this.config.scimgateway.emailOnError.smtp.username
|
|
430
429
|
this.config.scimgateway.email.auth.options.username = this.config.scimgateway.emailOnError.smtp.username
|
|
431
430
|
}
|
|
432
|
-
if (this.config.scimgateway
|
|
431
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.password) {
|
|
433
432
|
this.config.scimgateway.email.auth.options.password = this.config.scimgateway.emailOnError.smtp.password
|
|
434
|
-
this.config.scimgateway.email.auth.type = '
|
|
433
|
+
this.config.scimgateway.email.auth.type = 'smtp'
|
|
435
434
|
}
|
|
436
|
-
if (this.config.scimgateway
|
|
435
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.enabled) {
|
|
437
436
|
this.config.scimgateway.email.emailOnError.enabled = this.config.scimgateway.emailOnError.smtp.enabled
|
|
438
437
|
}
|
|
439
|
-
if (this.config.scimgateway
|
|
438
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.sendInterval) {
|
|
440
439
|
this.config.scimgateway.email.emailOnError.sendInterval = this.config.scimgateway.emailOnError.smtp.sendInterval
|
|
441
440
|
}
|
|
442
|
-
if (this.config.scimgateway
|
|
441
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.subject) {
|
|
442
|
+
this.config.scimgateway.email.emailOnError.subject = this.config.scimgateway.emailOnError.smtp.subject
|
|
443
|
+
}
|
|
444
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.to) {
|
|
443
445
|
this.config.scimgateway.email.emailOnError.to = this.config.scimgateway.emailOnError.smtp.to
|
|
444
446
|
}
|
|
445
|
-
if (this.config.scimgateway
|
|
447
|
+
if (this.config.scimgateway?.emailOnError?.smtp?.cc) {
|
|
446
448
|
this.config.scimgateway.email.emailOnError.cc = this.config.scimgateway.emailOnError.smtp.cc
|
|
447
449
|
}
|
|
448
450
|
// end - legacy support
|
|
@@ -2657,12 +2659,8 @@ export class ScimGateway {
|
|
|
2657
2659
|
setTimeout(function () { // release lock after "sendInterval" minutes
|
|
2658
2660
|
isMailLock = false
|
|
2659
2661
|
}, (this.config.scimgateway.email.emailOnError.sendInterval || 15) * 1000 * 60)
|
|
2660
|
-
const msgHtml = `<html><body>
|
|
2661
|
-
<p>${msg}</p>
|
|
2662
|
-
<br>
|
|
2663
|
-
<p><strong>This is an automatically generated email - please do NOT reply to this email or forward to others</strong></p>
|
|
2664
|
-
</body></html>`
|
|
2665
2662
|
|
|
2663
|
+
const msgHtml = `<html><body><pre style="font-family: monospace; white-space: pre-wrap;">${msg}</pre><br/><p><strong>This is an automatically generated email - please do NOT reply to this email or forward to others</strong></p></body></html>`
|
|
2666
2664
|
const msgObj = {
|
|
2667
2665
|
from: this.config.scimgateway.email.emailOnError.from,
|
|
2668
2666
|
to: this.config.scimgateway.email.emailOnError.to,
|
|
@@ -2947,33 +2945,27 @@ export class ScimGateway {
|
|
|
2947
2945
|
logger.error(`${gwName}[${pluginName}] sendMail failed: missing or invalid msgObj argument`)
|
|
2948
2946
|
return
|
|
2949
2947
|
}
|
|
2948
|
+
if (!isHtml) {
|
|
2949
|
+
isHtml = true
|
|
2950
|
+
msgObj.content = `<html><body><pre style="font-family: monospace; white-space: pre-wrap;">${msgObj.content}</pre></body></html>`
|
|
2951
|
+
}
|
|
2952
|
+
if (!msgObj.to) msgObj.to = ''
|
|
2953
|
+
if (!msgObj.cc) msgObj.cc = ''
|
|
2954
|
+
if (!msgObj.subject) msgObj.subject = 'SCIM Gateway message'
|
|
2950
2955
|
|
|
2951
2956
|
if (authType === 'oauth') {
|
|
2952
2957
|
if (!this.helperRest) this.helperRest = new HelperRest(this, { entity: { undefined: { connection: this.config.scimgateway.email } } })
|
|
2953
2958
|
if (this.config.scimgateway.email.auth?.options?.tenantIdGUID) {
|
|
2954
|
-
// Graph API
|
|
2955
|
-
let content: string
|
|
2956
|
-
if (isHtml) { // ExO workaround for national special caracters in html content - require singleValueExtendedProperties being used
|
|
2957
|
-
var enc = new TextEncoder() // utf-8
|
|
2958
|
-
const buf = enc.encode(msgObj.content)
|
|
2959
|
-
content = new TextDecoder('windows-1252').decode(buf)
|
|
2960
|
-
} else content = msgObj.content
|
|
2961
|
-
|
|
2959
|
+
// Microsoft Exchange Online (ExO) - using Graph API
|
|
2962
2960
|
const emailMessage: Record<string, any> = {
|
|
2963
2961
|
message: {
|
|
2964
|
-
subject: msgObj.subject
|
|
2962
|
+
subject: msgObj.subject,
|
|
2965
2963
|
body: {
|
|
2966
|
-
content,
|
|
2964
|
+
content: msgObj.content,
|
|
2967
2965
|
contentType: isHtml ? 'HTML' : 'Text',
|
|
2968
2966
|
},
|
|
2969
2967
|
toRecipients: [],
|
|
2970
2968
|
ccRecipients: [],
|
|
2971
|
-
singleValueExtendedProperties: [ // force using ExO header: Content-Type: text/plain; charset="utf-8"
|
|
2972
|
-
{
|
|
2973
|
-
id: 'Integer 0x3fde', // Content-Type header - can be verifed by checking raw mail
|
|
2974
|
-
value: '65001', // text/plain; charset="utf-8"
|
|
2975
|
-
},
|
|
2976
|
-
],
|
|
2977
2969
|
},
|
|
2978
2970
|
saveToSentItems: 'false',
|
|
2979
2971
|
}
|
|
@@ -2983,7 +2975,7 @@ export class ScimGateway {
|
|
|
2983
2975
|
for (let i = 0; i < arr.length; i++) {
|
|
2984
2976
|
emailMessage.message.toRecipients.push({
|
|
2985
2977
|
emailAddress: {
|
|
2986
|
-
address: arr[i],
|
|
2978
|
+
address: arr[i].trim(),
|
|
2987
2979
|
},
|
|
2988
2980
|
})
|
|
2989
2981
|
}
|
|
@@ -2993,7 +2985,7 @@ export class ScimGateway {
|
|
|
2993
2985
|
for (let i = 0; i < arr.length; i++) {
|
|
2994
2986
|
emailMessage.message.ccRecipients.push({
|
|
2995
2987
|
emailAddress: {
|
|
2996
|
-
address: arr[i],
|
|
2988
|
+
address: arr[i].trim(),
|
|
2997
2989
|
},
|
|
2998
2990
|
})
|
|
2999
2991
|
}
|
|
@@ -3004,34 +2996,59 @@ export class ScimGateway {
|
|
|
3004
2996
|
const path = `/users/${msgObj.from}/sendMail`
|
|
3005
2997
|
try {
|
|
3006
2998
|
await this.helperRest.doRequest('undefined', 'POST', path, emailMessage)
|
|
3007
|
-
logger.debug(`${gwName}[${pluginName}] sendMail subject '${
|
|
2999
|
+
logger.debug(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
|
|
3000
|
+
} catch (err: any) {
|
|
3001
|
+
logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: ${err.message}`)
|
|
3002
|
+
}
|
|
3003
|
+
return
|
|
3004
|
+
} else if (this.config.scimgateway.email.auth?.options?.serviceAccountKeyFile) {
|
|
3005
|
+
// Google Workspace Gmail
|
|
3006
|
+
let mimeMessage = `From: ${msgObj.from}
|
|
3007
|
+
To: ${msgObj.to}
|
|
3008
|
+
Cc: ${msgObj.cc}
|
|
3009
|
+
Subject: ${msgObj.subject}
|
|
3010
|
+
MIME-Version: 1.0
|
|
3011
|
+
Content-Type: text/html; charset="UTF-8"
|
|
3012
|
+
Content-Transfer-Encoding: quoted-printable
|
|
3013
|
+
|
|
3014
|
+
`
|
|
3015
|
+
mimeMessage += msgObj.content
|
|
3016
|
+
const encodedMessage = btoa(mimeMessage)
|
|
3017
|
+
const emailMessage = { raw: encodedMessage }
|
|
3018
|
+
const path = `/gmail/v1/users/${msgObj.from}/messages/send`
|
|
3019
|
+
try { // using opt connection argument type=oauthJwtBearer and options scope/subject because we want to keep simplified email.auth.type=oauth and options serviceAccountKeyFile
|
|
3020
|
+
await this.helperRest.doRequest('undefined', 'POST', path, emailMessage, null, { connection: { auth: { type: 'oauthJwtBearer', options: { scope: 'https://www.googleapis.com/auth/gmail.send', subject: msgObj.from } } } })
|
|
3021
|
+
logger.debug(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
|
|
3008
3022
|
} catch (err: any) {
|
|
3009
|
-
logger.error(`${gwName}[${pluginName}] sendMail subject '${
|
|
3023
|
+
logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: ${err.message}`)
|
|
3010
3024
|
}
|
|
3011
3025
|
return
|
|
3012
3026
|
}
|
|
3027
|
+
logger.error(`${gwName}[${pluginName}] sendMail error: type oauth supports only ExO (scimgateway.email.auth.options.tenantIdGUID) or Google Workspace Gmail (scimgateway.email.auth.options.serviceAccountKeyFile)`)
|
|
3028
|
+
return
|
|
3013
3029
|
}
|
|
3014
3030
|
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: some missing scimgateway.email configuration`)
|
|
3031
|
+
if (authType !== 'smtp') {
|
|
3032
|
+
logger.error(`${gwName}[${pluginName}] sendMail error: configuration scimgateway.email.auth.type must be set to oauth or smtp`)
|
|
3018
3033
|
return
|
|
3019
3034
|
}
|
|
3035
|
+
|
|
3036
|
+
// nodemailer - SMTP Auth
|
|
3020
3037
|
const smtpConfig: { [key: string]: any } = {
|
|
3021
|
-
host: this.config.scimgateway
|
|
3022
|
-
port: this.config.scimgateway
|
|
3023
|
-
|
|
3024
|
-
secure: (this.config.scimgateway.email.port === 465), // false on 25/587
|
|
3038
|
+
host: this.config.scimgateway?.email?.auth?.options?.host, // e.g. smtp.office365.com
|
|
3039
|
+
port: this.config.scimgateway?.email?.auth?.options?.port || 587,
|
|
3040
|
+
secure: (this.config.scimgateway?.email?.auth?.options?.port === 465), // false on 25/587
|
|
3025
3041
|
tls: { ciphers: 'TLSv1.2' },
|
|
3042
|
+
proxy: this.config.scimgateway?.email?.proxy,
|
|
3026
3043
|
}
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3044
|
+
|
|
3045
|
+
smtpConfig.auth = {}
|
|
3046
|
+
smtpConfig.auth.user = this.config.scimgateway?.email?.auth?.options?.username
|
|
3047
|
+
smtpConfig.auth.pass = this.config.scimgateway?.email?.auth?.options?.password
|
|
3048
|
+
|
|
3049
|
+
if (!this.config.scimgateway?.email?.auth?.options?.host || !this.config.scimgateway?.email?.auth?.options?.username) {
|
|
3050
|
+
logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending error: missing scimgateway.email.options configuration for auth type smtp`)
|
|
3051
|
+
return
|
|
3035
3052
|
}
|
|
3036
3053
|
|
|
3037
3054
|
const transporter = nodemailer.createTransport(smtpConfig)
|
|
@@ -3040,29 +3057,15 @@ export class ScimGateway {
|
|
|
3040
3057
|
from: msgObj.from, // sender address
|
|
3041
3058
|
to: msgObj.to, // list of receivers - comma separated
|
|
3042
3059
|
cc: msgObj.cc,
|
|
3043
|
-
subject: msgObj.subject
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
if (authType === 'oauth') {
|
|
3047
|
-
mailOptions.auth = {}
|
|
3048
|
-
mailOptions.auth.user = msgObj.from
|
|
3049
|
-
transporter.set('oauth2_provision_cb', async (user, renew, callback) => {
|
|
3050
|
-
const aObj = await this.helperRest.getAccessToken('undefined')
|
|
3051
|
-
const accessToken = aObj ? aObj?.access_token : null
|
|
3052
|
-
if (!accessToken) {
|
|
3053
|
-
return callback(new Error('missing access token'))
|
|
3054
|
-
} else {
|
|
3055
|
-
return callback(null, accessToken)
|
|
3056
|
-
}
|
|
3057
|
-
})
|
|
3060
|
+
subject: msgObj.subject,
|
|
3058
3061
|
}
|
|
3059
3062
|
|
|
3060
3063
|
if (isHtml) mailOptions.html = msgObj.content
|
|
3061
3064
|
else mailOptions.text = msgObj.content
|
|
3062
3065
|
|
|
3063
3066
|
transporter.sendMail(mailOptions, function (err) {
|
|
3064
|
-
if (err != null) logger.error(`${gwName}[${pluginName}] sendMail subject '${
|
|
3065
|
-
else logger.debug(`${gwName}[${pluginName}] sendMail subject '${
|
|
3067
|
+
if (err != null) logger.error(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sending failed: ${err.message}`)
|
|
3068
|
+
else logger.debug(`${gwName}[${pluginName}] sendMail subject '${msgObj.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
|
|
3066
3069
|
})
|
|
3067
3070
|
}
|
|
3068
3071
|
|
|
@@ -3116,6 +3119,12 @@ export class ScimGateway {
|
|
|
3116
3119
|
dotConfig[key] = keyFile
|
|
3117
3120
|
const addKey = key.replace(`.${lastKey}`, '.publicKeyContent')
|
|
3118
3121
|
dotConfig[addKey] = fs.readFileSync(keyFile)
|
|
3122
|
+
} else if (key.endsWith('.serviceAccountKeyFile')) { // Google Service Account Key json-file
|
|
3123
|
+
let keyFile = path.join(this.configDir, '/certs/', dotConfig[key])
|
|
3124
|
+
if (dotConfig[key].startsWith('/') || dotConfig[key].includes('\\')) {
|
|
3125
|
+
keyFile = dotConfig[key]
|
|
3126
|
+
}
|
|
3127
|
+
dotConfig[key] = keyFile
|
|
3119
3128
|
}
|
|
3120
3129
|
|
|
3121
3130
|
// process env, file and text
|
package/package.json
CHANGED