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 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** - Contains configuration for sending email from plugin or automated error notifications emailOnError. Note, for emailOnError only the first error will be sent until sendInterval have passed
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** - `basic` or `oauth`
444
- - **email.auth.options** - Authentication configuration options - note, different options for type basic and oauth
445
- - **email.auth.options.username (basic)** - Mail account for authentication normally same as sender of the email, e.g. "user@gmail.com"
446
- - **email.auth.options.password (basic)** - Mail account password
447
- - **email.auth.options.tenantIdGUID (oauth)** - Entra ID tenant id, mandatory/recommended when using Microsoft Exchange Online
448
- - **email.auth.options.tokenUrl (oauth)** - Token endpoint, mandatory when not using tenantIdGUID (Microsoft Exchange Online)
449
- - **email.auth.options.clientId (oauth)** - Client ID
450
- - **email.auth.options.clientSecret (oauth)** - Client Secret
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", note must correspond with email.auth.options being used and mailserver configuration
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 our **plugin code**.
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 (for SOAP Webservice endpoint we might use plugin-soap as a template)
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. If using CA Provisioning you could setup a SCIM endpoint using the port number you defined
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 (they are all highlighted by comments in existing code).
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]
@@ -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 'oauthSamlAssertion':
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: 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
- // Supporting no auth, header based auth (e.g., config {"options":{"headers":{"APIkey":"123"}}}),
236
- // basicAuth, bearerAuth, oauth, tokenAuth, oauthSamlAssertion and auth PassTrough using request header authorization
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 type 'basic' - missing configuration entity.${baseEntity}.connection.auth.options.username/password`)
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 type 'oauth' - missing configuration entity.${baseEntity}.connection.auth.options.clientId/clientSecret`)
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 'oauthSamlAssertion':
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 type 'oauthSamlAssertion' - missing configuration entity.${baseEntity}.connection.auth.options...`)
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) cli.options = utils.extendObj(cli.options, opt) // merge with argument options
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 `oauthSamlAssertion`
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", // simplified configuration for using Microsoft Graph API
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=**"oauthSamlAssertion"** having auth.options:
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
@@ -49,7 +49,7 @@ export class Log {
49
49
  )
50
50
  }
51
51
 
52
- private maskSecret = winston.format((info) => {
52
+ private maskSecret = winston.format((info: any) => {
53
53
  // mask json secrets
54
54
  let rePattern = new RegExp(this.reJson, 'i')
55
55
  let msg: string = info.message
@@ -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 (!this.config.scimgateway.emailOnError) this.config.scimgateway.emailOnError = {}
418
- if (!this.config.scimgateway.emailOnError.smtp) this.config.scimgateway.emailOnError.smtp = {}
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.emailOnError.smtp.port) {
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.emailOnError.smtp.proxy) {
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.emailOnError.smtp.username) {
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.emailOnError.smtp.password) {
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 = 'basic'
433
+ this.config.scimgateway.email.auth.type = 'smtp'
435
434
  }
436
- if (this.config.scimgateway.emailOnError.smtp.enabled) {
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.emailOnError.smtp.sendInterval) {
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.emailOnError.smtp.to) {
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.emailOnError.smtp.cc) {
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 ? msgObj.subject : 'SCIM Gateway message',
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 '${emailMessage.message.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
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 '${emailMessage.message.subject}' sending failed: ${err.message}`)
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
- // nodemailer
3016
- if (!this.config.scimgateway?.email?.host) {
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.email.host, // e.g. smtp.office365.com
3022
- port: this.config.scimgateway.email.port || 587,
3023
- proxy: this.config.scimgateway.email.proxy || null,
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
- if (authType) {
3028
- smtpConfig.auth = {}
3029
- if (authType === 'basic') {
3030
- smtpConfig.auth.user = this.config.scimgateway.email.auth.options.username
3031
- smtpConfig.auth.pass = this.config.scimgateway.email.auth.options.password
3032
- } else if (authType === 'oauth') {
3033
- smtpConfig.auth.type = 'OAuth2'
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 ? msgObj.subject : 'SCIM Gateway message',
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 '${mailOptions.subject}' sending failed: ${err.message}`)
3065
- else logger.debug(`${gwName}[${pluginName}] sendMail subject '${mailOptions.subject}' sent to: ${msgObj.to}${(msgObj.cc) ? ',' + msgObj.cc : ''}`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.0.13",
3
+ "version": "5.0.15",
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)",