emailengine-app 2.67.3 → 2.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +6 -0
  4. package/.github/workflows/test.yml +6 -0
  5. package/CHANGELOG.md +36 -0
  6. package/SECURITY.md +80 -0
  7. package/SECURITY.txt +27 -0
  8. package/data/google-crawlers.json +7 -1
  9. package/lib/account.js +24 -1
  10. package/lib/api-routes/account-routes.js +12 -2
  11. package/lib/email-client/base-client.js +26 -20
  12. package/lib/email-client/gmail-client.js +14 -12
  13. package/lib/imapproxy/imap-core/lib/imap-command.js +1 -1
  14. package/lib/imapproxy/imap-core/lib/imap-connection.js +7 -0
  15. package/lib/imapproxy/imap-core/lib/imap-server.js +1 -1
  16. package/lib/imapproxy/imap-server.js +92 -29
  17. package/lib/oauth/external-account-config.js +132 -0
  18. package/lib/oauth/external-account-signer.js +256 -0
  19. package/lib/oauth/gmail.js +113 -14
  20. package/lib/oauth/verify-app.js +397 -0
  21. package/lib/oauth2-apps.js +51 -6
  22. package/lib/routes-ui.js +153 -1
  23. package/lib/schemas.js +80 -2
  24. package/lib/settings.js +1 -0
  25. package/lib/tools.js +15 -10
  26. package/package.json +28 -28
  27. package/sbom.json +1 -1
  28. package/server.js +3 -3
  29. package/static/js/ace/ace.js +1 -1
  30. package/static/js/ace/ext-searchbox.js +1 -1
  31. package/static/js/ace/mode-handlebars.js +1 -1
  32. package/static/js/ace/mode-html.js +1 -1
  33. package/static/js/ace/mode-javascript.js +1 -1
  34. package/static/js/ace/mode-markdown.js +1 -1
  35. package/static/js/ace/worker-html.js +1 -1
  36. package/static/js/ace/worker-javascript.js +1 -1
  37. package/static/js/ace/worker-json.js +1 -1
  38. package/static/licenses.html +145 -115
  39. package/translations/messages.pot +49 -49
  40. package/views/config/oauth/app.hbs +224 -0
  41. package/views/config/oauth/edit.hbs +69 -0
  42. package/views/config/oauth/new.hbs +69 -0
  43. package/views/partials/oauth_form.hbs +99 -32
  44. package/workers/api.js +91 -2
@@ -25,18 +25,22 @@
25
25
  <div class="card-body">
26
26
  <div class="row no-gutters align-items-center">
27
27
  <div class="col mr-2">
28
- <strong>Service accounts</strong> allow EmailEngine to access Gmail mailboxes using a Google Cloud
29
- service key, with no interactive user login required. The service account must have domain-wide
30
- delegation enabled in Google Workspace. Use this for automated integrations where you cannot
31
- perform interactive logins.
32
- Accounts using service access can only be added via the
33
- <a href="/admin/swagger#/Account/postV1Account">REST API</a>,
34
- not through the hosted authentication form.
35
- {{#if providerData.tutorialUrl}}
36
- Read about setting up {{providerData.comment}} from <a
37
- href="{{providerData.tutorialUrl}}" target="_blank" rel="noopener noreferrer"
38
- referrerpolicy="no-referrer">here</a>.
39
- {{/if}}
28
+ <strong>Service accounts</strong> let EmailEngine access any mailbox in your Google Workspace
29
+ organization without an interactive user login. The service account must have domain-wide
30
+ delegation enabled in Workspace. Use this for automated integrations where you cannot perform
31
+ interactive logins.
32
+ <p class="mt-2 mb-0">
33
+ Two authentication methods are supported: a downloaded <strong>service account key</strong>
34
+ (classic) or <strong>Workload Identity Federation</strong> (keyless, for GKE, EKS, AKS, or
35
+ any OIDC-emitting workload). Pick one below.
36
+ </p>
37
+ <p class="mt-2 mb-0">
38
+ Accounts using service access can only be added via the
39
+ <a href="/admin/swagger#/Account/postV1Account">REST API</a>, not through the hosted
40
+ authentication form.{{#if providerData.tutorialUrl}} Read the
41
+ <a href="{{providerData.tutorialUrl}}" target="_blank" rel="noopener noreferrer"
42
+ referrerpolicy="no-referrer">setup guide</a> for both methods.{{/if}}
43
+ </p>
40
44
  </div>
41
45
  <div class="col-auto">
42
46
  <i class="fas fa-info-circle fa-2x text-gray-300"></i>
@@ -184,14 +188,58 @@
184
188
  {{#if activeGmailService}}
185
189
 
186
190
  <div class="form-group">
187
- <button type="button" class="btn btn-info btn-icon-split" id="serviceFile">
188
- <span class="icon text-white-50">
189
- <i class="fas fa-cloud-upload-alt"></i>
190
- </span>
191
- <span class="text">Load configuration from the service key file&#x2026;</span>
192
- </button>
193
- <small class="form-text text-muted">Select the JSON file you received after generating a new service key to
194
- retrieve the service values.</small>
191
+ <label class="d-block">Authentication method</label>
192
+ <input type="hidden" name="authMethod" id="authMethod"
193
+ value="{{#if authMethodIsExternalAccount}}externalAccount{{else}}serviceKey{{/if}}" />
194
+ {{#if authMethodLocked}}
195
+ <p class="mb-1">
196
+ <strong>{{#if authMethodIsExternalAccount}}Workload Identity Federation (keyless){{else}}Service account key{{/if}}</strong>
197
+ </p>
198
+ <small class="form-text text-muted">The authentication method is fixed once the application has been created and
199
+ cannot be changed.</small>
200
+ {{else}}
201
+ <ul class="nav nav-tabs" id="auth-method-tabs" role="tablist">
202
+ <li class="nav-item" role="presentation">
203
+ <a class="nav-link auth-method-tab {{#if authMethodIsServiceKey}}active{{/if}}"
204
+ id="auth-method-tab-serviceKey" data-auth-method="serviceKey" href="#" role="tab"
205
+ aria-selected="{{#if authMethodIsServiceKey}}true{{else}}false{{/if}}">Service account key</a>
206
+ </li>
207
+ <li class="nav-item" role="presentation">
208
+ <a class="nav-link auth-method-tab {{#if authMethodIsExternalAccount}}active{{/if}}"
209
+ id="auth-method-tab-externalAccount" data-auth-method="externalAccount" href="#" role="tab"
210
+ aria-selected="{{#if authMethodIsExternalAccount}}true{{else}}false{{/if}}">Workload Identity Federation (keyless)</a>
211
+ </li>
212
+ </ul>
213
+ <small class="form-text text-muted">"Service account key" expects a downloaded JSON key file.
214
+ "Workload Identity Federation" authenticates without a long-lived key, using a credential issued
215
+ by your runtime environment.</small>
216
+ {{/if}}
217
+ </div>
218
+
219
+ <div class="auth-method-section auth-method-section-serviceKey {{#unless authMethodIsServiceKey}}d-none{{/unless}}">
220
+ <div class="form-group">
221
+ <button type="button" class="btn btn-info btn-icon-split" id="serviceFile">
222
+ <span class="icon text-white-50">
223
+ <i class="fas fa-cloud-upload-alt"></i>
224
+ </span>
225
+ <span class="text">Load configuration from the service key file&#x2026;</span>
226
+ </button>
227
+ <small class="form-text text-muted">Select the JSON file you received after generating a new service key to
228
+ retrieve the service values.</small>
229
+ </div>
230
+ </div>
231
+
232
+ <div class="auth-method-section auth-method-section-externalAccount {{#unless authMethodIsExternalAccount}}d-none{{/unless}}">
233
+ <div class="form-group">
234
+ <button type="button" class="btn btn-info btn-icon-split" id="externalAccountFile">
235
+ <span class="icon text-white-50">
236
+ <i class="fas fa-cloud-upload-alt"></i>
237
+ </span>
238
+ <span class="text">Load configuration from the external-account JSON file&#x2026;</span>
239
+ </button>
240
+ <small class="form-text text-muted">Select the credential configuration file produced by
241
+ <code>gcloud iam workload-identity-pools create-cred-config</code>.</small>
242
+ </div>
195
243
  </div>
196
244
 
197
245
  <div class="form-group">
@@ -235,18 +283,37 @@
235
283
  <small class="form-text text-muted">OAuth2 Service Client ID</small>
236
284
  </div>
237
285
 
238
- <div class="form-group">
286
+ <div class="auth-method-section auth-method-section-serviceKey {{#unless authMethodIsServiceKey}}d-none{{/unless}}">
287
+ <div class="form-group">
239
288
 
240
- <label for="serviceKey">Secret service key</label>
289
+ <label for="serviceKey">Secret service key</label>
241
290
 
242
- <textarea class="form-control droptxt autoselect {{#if errors.serviceKey}}is-invalid{{/if}}" id="serviceKey"
243
- name="serviceKey" rows="4" data-enable-grammarly="false" spellcheck="false" {{#if
244
- hasServiceKey}}placeholder="Service key is set but not shown&mldr;" {{else}}
245
- placeholder="Starts with &quot;-----BEGIN PRIVATE KEY-----&quot;&mldr;" {{/if}} {{#unless
246
- hasServiceKey}}required{{/unless}}>{{values.serviceKey}}</textarea>
247
- {{#if errors.serviceKey}}
248
- <span class="invalid-feedback">{{errors.serviceKey}}</span>
249
- {{/if}}
291
+ <textarea class="form-control droptxt autoselect {{#if errors.serviceKey}}is-invalid{{/if}}" id="serviceKey"
292
+ name="serviceKey" rows="8" data-enable-grammarly="false" spellcheck="false" {{#if
293
+ hasServiceKey}}placeholder="Service key is set but not shown&mldr;" {{else}}
294
+ placeholder="Starts with &quot;-----BEGIN PRIVATE KEY-----&quot;&mldr;" {{/if}}>{{values.serviceKey}}</textarea>
295
+ {{#if errors.serviceKey}}
296
+ <span class="invalid-feedback">{{errors.serviceKey}}</span>
297
+ {{/if}}
298
+ </div>
299
+ </div>
300
+
301
+ <div class="auth-method-section auth-method-section-externalAccount {{#unless authMethodIsExternalAccount}}d-none{{/unless}}">
302
+ <div class="form-group">
303
+
304
+ <label for="externalAccount">External account configuration</label>
305
+
306
+ <textarea class="form-control droptxt autoselect text-monospace {{#if errors.externalAccount}}is-invalid{{/if}}"
307
+ id="externalAccount" name="externalAccount" rows="8" data-enable-grammarly="false" spellcheck="false" {{#if
308
+ hasExternalAccount}}placeholder="External account configuration is set but not shown&mldr;" {{else}}
309
+ placeholder="{&quot;type&quot;:&quot;external_account&quot;,&quot;audience&quot;:&quot;...&quot;,&quot;credential_source&quot;:{&quot;file&quot;:&quot;/var/run/secrets/...&quot;}}" {{/if}}>{{values.externalAccount}}</textarea>
310
+ {{#if errors.externalAccount}}
311
+ <span class="invalid-feedback">{{errors.externalAccount}}</span>
312
+ {{/if}}
313
+ <small class="form-text text-muted">Paste the JSON produced by
314
+ <code>gcloud iam workload-identity-pools create-cred-config</code>. Only the
315
+ <code>file</code> and <code>url</code> credential sources are supported.</small>
316
+ </div>
250
317
  </div>
251
318
 
252
319
  {{else if activeOutlookService}}
@@ -811,7 +878,7 @@
811
878
 
812
879
  <div id="select-pubsub-app" class="card-footer {{#unless baseScopesApi}}d-none{{/unless}}">
813
880
 
814
- <p>Microsoft Graph delivers change notifications to EmailEngine at these two endpoints:</p>
881
+ <p>Microsoft Graph delivers change notifications to EmailEngine at these two endpoints:</p>
815
882
 
816
883
  <pre><code>{{mainServiceUrl}}/oauth/msg/lifecycle
817
884
  {{mainServiceUrl}}/oauth/msg/notification</code></pre>
@@ -825,7 +892,7 @@
825
892
  <p>
826
893
  If your EmailEngine instance cannot be exposed directly, configure a public
827
894
  proxy domain in <span class="text-muted code-link"><a href="/admin/swagger#/Settings/postV1Settings"
828
- target="_blank" rel="noopener noreferrer">notificationBaseUrl</a></span>. Microsoft Graph will then
895
+ target="_blank" rel="noopener noreferrer">notificationBaseUrl</a></span>. Microsoft Graph will then
829
896
  post to <code>https://&lt;proxy-domain&gt;/oauth/msg/...</code> instead of
830
897
  <code>{{mainServiceUrl}}</code>.
831
898
  </p>
package/workers/api.js CHANGED
@@ -84,6 +84,7 @@ const pathlib = require('path');
84
84
  const crypto = require('crypto');
85
85
  const { Transform, finished } = require('stream');
86
86
  const { oauth2Apps, OAUTH_PROVIDERS } = require('../lib/oauth2-apps');
87
+ const { verifyOAuth2App } = require('../lib/oauth/verify-app');
87
88
 
88
89
  const handlebars = require('handlebars');
89
90
  const AuthBearer = require('hapi-auth-bearer-token');
@@ -5016,7 +5017,7 @@ Include your token in requests using one of these methods:
5016
5017
  let response = await oauth2Apps.list(request.query.page, request.query.pageSize);
5017
5018
 
5018
5019
  for (let app of response.apps) {
5019
- for (let secretKey of ['clientSecret', 'serviceKey', 'accessToken']) {
5020
+ for (let secretKey of ['clientSecret', 'serviceKey', 'accessToken', 'externalAccount']) {
5020
5021
  if (app[secretKey]) {
5021
5022
  app[secretKey] = '******';
5022
5023
  }
@@ -5170,7 +5171,7 @@ Include your token in requests using one of these methods:
5170
5171
  let app = await oauth2Apps.get(request.params.app);
5171
5172
 
5172
5173
  // remove secrets
5173
- for (let secretKey of ['clientSecret', 'serviceKey', 'accessToken']) {
5174
+ for (let secretKey of ['clientSecret', 'serviceKey', 'accessToken', 'externalAccount']) {
5174
5175
  if (app[secretKey]) {
5175
5176
  app[secretKey] = '******';
5176
5177
  }
@@ -5576,6 +5577,94 @@ Include your token in requests using one of these methods:
5576
5577
  }
5577
5578
  });
5578
5579
 
5580
+ server.route({
5581
+ method: 'POST',
5582
+ path: '/v1/oauth2/{app}/verify',
5583
+
5584
+ async handler(request) {
5585
+ try {
5586
+ return await verifyOAuth2App(request.params.app, {
5587
+ account: request.payload.account,
5588
+ testConnection: request.payload.testConnection
5589
+ });
5590
+ } catch (err) {
5591
+ request.logger.error({ msg: 'API request failed', err });
5592
+ if (Boom.isBoom(err)) {
5593
+ throw err;
5594
+ }
5595
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
5596
+ if (err.code) {
5597
+ error.output.payload.code = err.code;
5598
+ }
5599
+ throw error;
5600
+ }
5601
+ },
5602
+ options: {
5603
+ description: 'Verify OAuth2 application setup',
5604
+ notes: 'Runs the provider authentication chain step by step and reports which steps pass or fail, with hints for fixing failures. For service-account apps an optional mailbox address enables the delegation and live mailbox checks.',
5605
+ tags: ['api', 'OAuth2 Applications'],
5606
+
5607
+ plugins: {},
5608
+
5609
+ auth: {
5610
+ strategy: 'api-token',
5611
+ mode: 'required'
5612
+ },
5613
+ cors: CORS_CONFIG,
5614
+
5615
+ validate: {
5616
+ options: {
5617
+ stripUnknown: false,
5618
+ abortEarly: false,
5619
+ convert: true
5620
+ },
5621
+ failAction,
5622
+
5623
+ params: Joi.object({
5624
+ app: Joi.string().max(256).required().example('AAABhaBPHscAAAAH').description('OAuth2 application ID')
5625
+ }),
5626
+
5627
+ payload: Joi.object({
5628
+ account: Joi.string()
5629
+ .trim()
5630
+ .empty('')
5631
+ .max(256)
5632
+ .example('user@example.com')
5633
+ .description('Mailbox address used to verify domain-wide delegation and live mailbox access'),
5634
+ testConnection: Joi.boolean()
5635
+ .truthy('Y', 'true', '1', 'on')
5636
+ .falsy('N', 'false', 0, '')
5637
+ .default(true)
5638
+ .description('Perform the live IMAP/API connection step when an access token is obtained')
5639
+ }).label('VerifyOAuth2AppRequest')
5640
+ },
5641
+
5642
+ response: {
5643
+ schema: Joi.object({
5644
+ app: Joi.string().max(256).required().example('AAABhaBPHscAAAAH').description('OAuth2 application ID'),
5645
+ provider: Joi.string().example('gmailService').description('Provider type'),
5646
+ authMethod: Joi.string().allow(null).example('externalAccount').description('Authentication method for service-account apps'),
5647
+ account: Joi.string().allow(null).example('user@example.com').description('Mailbox used for the delegation/mailbox checks'),
5648
+ ok: Joi.boolean().example(true).description('True when no verification step failed'),
5649
+ steps: Joi.array()
5650
+ .items(
5651
+ Joi.object({
5652
+ id: Joi.string().example('signJwt').description('Step identifier'),
5653
+ label: Joi.string().example('Sign assertion (signJwt)').description('Human readable step name'),
5654
+ status: Joi.string().valid('ok', 'fail', 'skip').example('ok').description('Step outcome'),
5655
+ message: Joi.string().allow(null).example('Assertion signed via IAM signJwt').description('Outcome detail'),
5656
+ hint: Joi.string()
5657
+ .example('Grant roles/iam.serviceAccountTokenCreator to the workload principal')
5658
+ .description('How to fix a failed step')
5659
+ }).label('OAuth2VerifyStep')
5660
+ )
5661
+ .label('OAuth2VerifySteps')
5662
+ }).label('VerifyOAuth2AppResponse'),
5663
+ failAction: 'log'
5664
+ }
5665
+ }
5666
+ });
5667
+
5579
5668
  server.route({
5580
5669
  method: 'GET',
5581
5670
  path: '/v1/gateways',