emailengine-app 2.63.4 → 2.65.0
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/.github/workflows/test.yml +4 -0
- package/CHANGELOG.md +70 -0
- package/copy-static-files.sh +1 -1
- package/data/google-crawlers.json +1 -1
- package/eslint.config.js +2 -0
- package/lib/account.js +13 -9
- package/lib/api-routes/account-routes.js +7 -1
- package/lib/consts.js +17 -1
- package/lib/email-client/gmail/gmail-api.js +1 -12
- package/lib/email-client/imap-client.js +5 -3
- package/lib/email-client/outlook/graph-api.js +9 -15
- package/lib/email-client/outlook-client.js +406 -177
- package/lib/export.js +17 -0
- package/lib/imapproxy/imap-server.js +3 -2
- package/lib/oauth/gmail.js +12 -1
- package/lib/oauth/outlook.js +99 -1
- package/lib/oauth/pubsub/google.js +253 -85
- package/lib/oauth2-apps.js +620 -389
- package/lib/outbox.js +1 -1
- package/lib/routes-ui.js +193 -238
- package/lib/schemas.js +189 -12
- package/lib/ui-routes/account-routes.js +7 -2
- package/lib/ui-routes/admin-entities-routes.js +3 -3
- package/lib/ui-routes/oauth-routes.js +27 -175
- package/package.json +21 -21
- package/sbom.json +1 -1
- package/server.js +54 -22
- package/static/licenses.html +30 -90
- package/translations/de.mo +0 -0
- package/translations/de.po +54 -42
- package/translations/en.mo +0 -0
- package/translations/en.po +55 -43
- package/translations/et.mo +0 -0
- package/translations/et.po +54 -42
- package/translations/fr.mo +0 -0
- package/translations/fr.po +54 -42
- package/translations/ja.mo +0 -0
- package/translations/ja.po +54 -42
- package/translations/messages.pot +93 -71
- package/translations/nl.mo +0 -0
- package/translations/nl.po +54 -42
- package/translations/pl.mo +0 -0
- package/translations/pl.po +54 -42
- package/views/config/oauth/app.hbs +12 -0
- package/views/config/oauth/edit.hbs +2 -0
- package/views/config/oauth/index.hbs +4 -1
- package/views/config/oauth/new.hbs +2 -0
- package/views/config/oauth/subscriptions.hbs +175 -0
- package/views/error.hbs +4 -4
- package/views/partials/oauth_form.hbs +179 -4
- package/views/partials/oauth_tabs.hbs +8 -0
- package/views/partials/scope_info.hbs +10 -0
- package/workers/api.js +174 -96
- package/workers/documents.js +1 -0
- package/workers/export.js +6 -2
- package/workers/imap.js +33 -49
- package/workers/smtp.js +1 -0
- package/workers/submit.js +1 -0
- package/workers/webhooks.js +42 -30
package/lib/oauth2-apps.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { redis } = require('./db');
|
|
4
4
|
const msgpack = require('msgpack5')();
|
|
5
5
|
const logger = require('./logger');
|
|
6
|
-
const { REDIS_PREFIX } = require('./consts');
|
|
6
|
+
const { REDIS_PREFIX, TRANSIENT_NETWORK_CODES, GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL, GMAIL_PUBSUB_ACK_DEADLINE_SECONDS } = require('./consts');
|
|
7
7
|
const { encrypt, decrypt } = require('./encrypt');
|
|
8
8
|
const Boom = require('@hapi/boom');
|
|
9
9
|
const settings = require('./settings');
|
|
@@ -11,17 +11,6 @@ const Lock = require('ioredfour');
|
|
|
11
11
|
const getSecret = require('./get-secret');
|
|
12
12
|
const { parentPort } = require('worker_threads');
|
|
13
13
|
|
|
14
|
-
const TRANSIENT_NETWORK_CODES = new Set([
|
|
15
|
-
'ENOTFOUND',
|
|
16
|
-
'EAI_AGAIN',
|
|
17
|
-
'ETIMEDOUT',
|
|
18
|
-
'ECONNRESET',
|
|
19
|
-
'ECONNREFUSED',
|
|
20
|
-
'UND_ERR_SOCKET',
|
|
21
|
-
'UND_ERR_CONNECT_TIMEOUT',
|
|
22
|
-
'UND_ERR_HEADERS_TIMEOUT'
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
14
|
/**
|
|
26
15
|
* Record metrics for OAuth2 token operations
|
|
27
16
|
* Works in both main thread and worker threads
|
|
@@ -54,13 +43,20 @@ const { MailRuOauth, MAIL_RU_SCOPES } = require('./oauth/mail-ru');
|
|
|
54
43
|
const LEGACY_KEYS = ['gmail', 'gmailService', 'outlook', 'mailRu'];
|
|
55
44
|
const LEGACY_KEYS_REV = JSON.parse(JSON.stringify(LEGACY_KEYS)).reverse();
|
|
56
45
|
|
|
46
|
+
const PUBSUB_PERM_MANAGE = 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
|
|
47
|
+
const PUBSUB_PERM_VIEW = 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
|
|
48
|
+
|
|
57
49
|
const OAUTH_PROVIDERS = {
|
|
58
50
|
gmail: 'Gmail',
|
|
59
51
|
gmailService: 'Gmail Service Accounts',
|
|
60
|
-
outlook: 'Outlook',
|
|
52
|
+
outlook: 'Outlook (delegated)',
|
|
53
|
+
outlookService: 'Outlook (application)',
|
|
61
54
|
mailRu: 'Mail.ru'
|
|
62
55
|
};
|
|
63
56
|
|
|
57
|
+
// Providers that use app-only credentials (no interactive user login)
|
|
58
|
+
const SERVICE_ACCOUNT_PROVIDERS = new Set(['gmailService', 'outlookService']);
|
|
59
|
+
|
|
64
60
|
const lock = new Lock({
|
|
65
61
|
redis,
|
|
66
62
|
namespace: 'ee'
|
|
@@ -108,6 +104,7 @@ function oauth2ProviderData(provider, selector) {
|
|
|
108
104
|
};
|
|
109
105
|
break;
|
|
110
106
|
|
|
107
|
+
case 'outlookService':
|
|
111
108
|
case 'outlook':
|
|
112
109
|
{
|
|
113
110
|
let imapHost = 'outlook.office365.com';
|
|
@@ -129,8 +126,12 @@ function oauth2ProviderData(provider, selector) {
|
|
|
129
126
|
}
|
|
130
127
|
|
|
131
128
|
providerData.icon = 'fab fa-microsoft';
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
if (provider === 'outlook') {
|
|
130
|
+
providerData.tutorialUrl = 'https://emailengine.app/outlook-and-ms-365';
|
|
131
|
+
providerData.linkImage = '/static/providers/ms_light.svg';
|
|
132
|
+
} else {
|
|
133
|
+
providerData.tutorialUrl = 'https://learn.emailengine.app/docs/accounts/outlook-client-credentials';
|
|
134
|
+
}
|
|
134
135
|
providerData.imap = {
|
|
135
136
|
host: imapHost,
|
|
136
137
|
port: 993,
|
|
@@ -176,11 +177,13 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
|
|
|
176
177
|
defaultScopes = (baseScopes && defaultScopesList[baseScopes]) || defaultScopesList.imap;
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
let extras = [];
|
|
180
180
|
if (!extraScopes && !skipScopes.length) {
|
|
181
181
|
return defaultScopes;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
extraScopes = extraScopes || [];
|
|
185
|
+
|
|
186
|
+
let extras = [];
|
|
184
187
|
for (let extraScope of extraScopes) {
|
|
185
188
|
if (defaultScopes.includes(extraScope) || (scopePrefix && defaultScopes.includes(`${scopePrefix}/${extraScope}`))) {
|
|
186
189
|
// skip existing
|
|
@@ -192,19 +195,16 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
|
|
|
192
195
|
let result = extras.length ? extras.concat(defaultScopes) : defaultScopes;
|
|
193
196
|
|
|
194
197
|
if (skipScopes.length) {
|
|
195
|
-
result = result.filter(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
return true;
|
|
207
|
-
});
|
|
198
|
+
result = result.filter(
|
|
199
|
+
scope =>
|
|
200
|
+
!skipScopes.some(
|
|
201
|
+
skipScope =>
|
|
202
|
+
scope === skipScope ||
|
|
203
|
+
scope === `https://outlook.office.com/${skipScope}` ||
|
|
204
|
+
scope === `https://graph.microsoft.com/${skipScope}` ||
|
|
205
|
+
scope === `https://www.googleapis.com/auth/${skipScope}`
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
return result;
|
|
@@ -216,6 +216,9 @@ class OAuth2AppsHandler {
|
|
|
216
216
|
this.redis = this.options.redis;
|
|
217
217
|
|
|
218
218
|
this.secret = null;
|
|
219
|
+
|
|
220
|
+
this._pubSubBackfillDone = false;
|
|
221
|
+
this._pubSubBackfillPromise = null;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
async encrypt(value) {
|
|
@@ -252,6 +255,56 @@ class OAuth2AppsHandler {
|
|
|
252
255
|
return `${REDIS_PREFIX}settings`;
|
|
253
256
|
}
|
|
254
257
|
|
|
258
|
+
async backfillPubSubApps() {
|
|
259
|
+
if (this._pubSubBackfillDone) {
|
|
260
|
+
return await this.redis.smembers(this.getSubscribersKey());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (this._pubSubBackfillPromise) {
|
|
264
|
+
return await this._pubSubBackfillPromise;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this._pubSubBackfillPromise = this._doBackfillPubSubApps();
|
|
268
|
+
try {
|
|
269
|
+
return await this._pubSubBackfillPromise;
|
|
270
|
+
} finally {
|
|
271
|
+
this._pubSubBackfillPromise = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async _doBackfillPubSubApps() {
|
|
276
|
+
let [subscriberIds, allIds] = await Promise.all([this.redis.smembers(this.getSubscribersKey()), this.redis.smembers(this.getIndexKey())]);
|
|
277
|
+
let subscriberSet = new Set(subscriberIds);
|
|
278
|
+
let missingIds = allIds.filter(id => !subscriberSet.has(id));
|
|
279
|
+
if (!missingIds.length) {
|
|
280
|
+
this._pubSubBackfillDone = true;
|
|
281
|
+
return subscriberIds;
|
|
282
|
+
}
|
|
283
|
+
let bufKeys = missingIds.map(id => `${id}:data`);
|
|
284
|
+
let entries = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
|
|
285
|
+
let toAdd = [];
|
|
286
|
+
for (let i = 0; i < entries.length; i++) {
|
|
287
|
+
if (!entries[i]) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
let data = msgpack.decode(entries[i]);
|
|
292
|
+
if (data.baseScopes === 'pubsub') {
|
|
293
|
+
toAdd.push(missingIds[i]);
|
|
294
|
+
logger.info({ msg: 'Backfilled pubsub app to subscribers set', app: missingIds[i] });
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
logger.warn({ msg: 'Failed to decode app data during backfill', app: missingIds[i], err });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (toAdd.length) {
|
|
301
|
+
await this.redis.sadd(this.getSubscribersKey(), ...toAdd);
|
|
302
|
+
subscriberIds.push(...toAdd);
|
|
303
|
+
}
|
|
304
|
+
this._pubSubBackfillDone = true;
|
|
305
|
+
return subscriberIds;
|
|
306
|
+
}
|
|
307
|
+
|
|
255
308
|
async list(page, pageSize, opts) {
|
|
256
309
|
opts = opts || {};
|
|
257
310
|
page = Math.max(Number(page) || 0, 0);
|
|
@@ -259,14 +312,12 @@ class OAuth2AppsHandler {
|
|
|
259
312
|
|
|
260
313
|
let startPos = page * pageSize;
|
|
261
314
|
|
|
262
|
-
let
|
|
315
|
+
let idList;
|
|
263
316
|
if (opts.pubsub) {
|
|
264
|
-
|
|
317
|
+
idList = await this.backfillPubSubApps();
|
|
265
318
|
} else {
|
|
266
|
-
|
|
319
|
+
idList = await this.redis.smembers(this.getIndexKey());
|
|
267
320
|
}
|
|
268
|
-
|
|
269
|
-
let idList = await this.redis.smembers(this[keyFunc]());
|
|
270
321
|
idList = [].concat(idList || []).sort((a, b) => -a.localeCompare(b));
|
|
271
322
|
|
|
272
323
|
if (!opts.pubsub) {
|
|
@@ -303,7 +354,7 @@ class OAuth2AppsHandler {
|
|
|
303
354
|
let data = await this.get(legacyKey);
|
|
304
355
|
response.apps.push(data);
|
|
305
356
|
} catch (err) {
|
|
306
|
-
logger.error({ msg: 'Failed to process legacy app', legacyKey });
|
|
357
|
+
logger.error({ msg: 'Failed to process legacy app', legacyKey, err });
|
|
307
358
|
continue;
|
|
308
359
|
}
|
|
309
360
|
}
|
|
@@ -311,18 +362,21 @@ class OAuth2AppsHandler {
|
|
|
311
362
|
if (keys.length) {
|
|
312
363
|
let bufKeys = keys.flatMap(id => [`${id}:data`, `${id}:meta`]);
|
|
313
364
|
let list = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
|
|
314
|
-
for (let i = 0; i < list.length; i
|
|
315
|
-
let
|
|
365
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
366
|
+
let dataEntry = list[i];
|
|
367
|
+
let metaEntry = list[i + 1];
|
|
316
368
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
369
|
+
let data = msgpack.decode(dataEntry);
|
|
370
|
+
if (metaEntry) {
|
|
371
|
+
try {
|
|
372
|
+
data.meta = msgpack.decode(metaEntry);
|
|
373
|
+
} catch (metaErr) {
|
|
374
|
+
logger.error({ msg: 'Failed to process app meta', entryLength: metaEntry?.length || 0 });
|
|
375
|
+
}
|
|
322
376
|
}
|
|
377
|
+
response.apps.push(data);
|
|
323
378
|
} catch (err) {
|
|
324
|
-
logger.error({ msg: 'Failed to process app',
|
|
325
|
-
continue;
|
|
379
|
+
logger.error({ msg: 'Failed to process app', entryLength: dataEntry?.length || 0 });
|
|
326
380
|
}
|
|
327
381
|
}
|
|
328
382
|
}
|
|
@@ -357,7 +411,7 @@ class OAuth2AppsHandler {
|
|
|
357
411
|
|
|
358
412
|
response.apps.forEach(app => {
|
|
359
413
|
app.includeInListing = !!app.enabled;
|
|
360
|
-
if (
|
|
414
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(app.provider)) {
|
|
361
415
|
// service accounts are always enabled
|
|
362
416
|
app.enabled = true;
|
|
363
417
|
app.includeInListing = false;
|
|
@@ -370,13 +424,11 @@ class OAuth2AppsHandler {
|
|
|
370
424
|
async generateId() {
|
|
371
425
|
let idNum = await this.redis.hincrby(this.getSettingsKey(), 'idcount', 1);
|
|
372
426
|
|
|
373
|
-
let idBuf = Buffer.alloc(
|
|
427
|
+
let idBuf = Buffer.alloc(12);
|
|
374
428
|
idBuf.writeBigUInt64BE(BigInt(Date.now()), 0);
|
|
375
429
|
idBuf.writeUInt32BE(idNum, 8);
|
|
376
430
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return id;
|
|
431
|
+
return idBuf.toString('base64url');
|
|
380
432
|
}
|
|
381
433
|
|
|
382
434
|
async getLegacyApp(id) {
|
|
@@ -450,7 +502,7 @@ class OAuth2AppsHandler {
|
|
|
450
502
|
extraScopes,
|
|
451
503
|
skipScopes,
|
|
452
504
|
|
|
453
|
-
name: 'Outlook',
|
|
505
|
+
name: 'Outlook (delegated)',
|
|
454
506
|
description: 'Legacy OAuth2 app',
|
|
455
507
|
|
|
456
508
|
meta: {
|
|
@@ -572,7 +624,7 @@ class OAuth2AppsHandler {
|
|
|
572
624
|
// legacy
|
|
573
625
|
let data = await this.getLegacyApp(id);
|
|
574
626
|
data.includeInListing = !!data.enabled;
|
|
575
|
-
if (
|
|
627
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
|
|
576
628
|
// service account are always enabled
|
|
577
629
|
data.enabled = true;
|
|
578
630
|
data.includeInListing = false;
|
|
@@ -589,20 +641,20 @@ class OAuth2AppsHandler {
|
|
|
589
641
|
try {
|
|
590
642
|
data = msgpack.decode(getDataBuf);
|
|
591
643
|
} catch (err) {
|
|
592
|
-
logger.error({ msg: 'Failed to process app', app: id,
|
|
644
|
+
logger.error({ msg: 'Failed to process app', app: id, entryLength: getDataBuf?.length || 0 });
|
|
593
645
|
throw err;
|
|
594
646
|
}
|
|
595
647
|
|
|
596
648
|
if (getMetaBuf) {
|
|
597
649
|
try {
|
|
598
|
-
data =
|
|
650
|
+
data.meta = msgpack.decode(getMetaBuf);
|
|
599
651
|
} catch (err) {
|
|
600
|
-
logger.error({ msg: 'Failed to process app', app: id,
|
|
652
|
+
logger.error({ msg: 'Failed to process app meta', app: id, entryLength: getMetaBuf?.length || 0 });
|
|
601
653
|
}
|
|
602
654
|
}
|
|
603
655
|
|
|
604
656
|
data.includeInListing = !!data.enabled;
|
|
605
|
-
if (
|
|
657
|
+
if (SERVICE_ACCOUNT_PROVIDERS.has(data.provider)) {
|
|
606
658
|
// service account are always enabled
|
|
607
659
|
data.enabled = true;
|
|
608
660
|
data.includeInListing = false;
|
|
@@ -635,7 +687,7 @@ class OAuth2AppsHandler {
|
|
|
635
687
|
[`${id}:data`]: msgpack.encode(entry)
|
|
636
688
|
});
|
|
637
689
|
|
|
638
|
-
if (data.pubSubSubscription) {
|
|
690
|
+
if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
|
|
639
691
|
insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
|
|
640
692
|
}
|
|
641
693
|
|
|
@@ -645,9 +697,9 @@ class OAuth2AppsHandler {
|
|
|
645
697
|
|
|
646
698
|
let insertResult = await insertResultReq.exec();
|
|
647
699
|
|
|
648
|
-
let
|
|
649
|
-
if (
|
|
650
|
-
throw
|
|
700
|
+
let errorEntry = insertResult.find(entry => entry && entry[0]);
|
|
701
|
+
if (errorEntry) {
|
|
702
|
+
throw errorEntry[0];
|
|
651
703
|
}
|
|
652
704
|
|
|
653
705
|
const result = {
|
|
@@ -655,17 +707,7 @@ class OAuth2AppsHandler {
|
|
|
655
707
|
created: true
|
|
656
708
|
};
|
|
657
709
|
|
|
658
|
-
|
|
659
|
-
let appData = await this.get(id);
|
|
660
|
-
if (appData.baseScopes === 'pubsub') {
|
|
661
|
-
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
662
|
-
if (Object.keys(pubsubUpdates || {})) {
|
|
663
|
-
result.pubsubUpdates = pubsubUpdates;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
} catch (err) {
|
|
667
|
-
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
668
|
-
}
|
|
710
|
+
await this.tryEnsurePubsub(id, result);
|
|
669
711
|
|
|
670
712
|
return result;
|
|
671
713
|
}
|
|
@@ -687,6 +729,8 @@ class OAuth2AppsHandler {
|
|
|
687
729
|
|
|
688
730
|
let existingData = msgpack.decode(existingDataBuf);
|
|
689
731
|
|
|
732
|
+
let oldPubSubApp = existingData.pubSubApp || null;
|
|
733
|
+
|
|
690
734
|
let encryptedValues = {};
|
|
691
735
|
for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
|
|
692
736
|
if (data[key]) {
|
|
@@ -706,22 +750,25 @@ class OAuth2AppsHandler {
|
|
|
706
750
|
|
|
707
751
|
let insertResultReq = this.redis.multi().sadd(this.getIndexKey(), id).hmset(this.getDataKey(), updates);
|
|
708
752
|
|
|
709
|
-
if (data.pubSubSubscription) {
|
|
753
|
+
if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
|
|
710
754
|
insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
|
|
711
755
|
}
|
|
712
756
|
|
|
713
757
|
if (data.pubSubApp) {
|
|
714
|
-
if (
|
|
715
|
-
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(
|
|
758
|
+
if (oldPubSubApp && oldPubSubApp !== data.pubSubApp) {
|
|
759
|
+
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
|
|
716
760
|
}
|
|
717
761
|
insertResultReq = insertResultReq.sadd(this.getPubsubAppKey(data.pubSubApp), id);
|
|
762
|
+
} else if (oldPubSubApp && 'pubSubApp' in data) {
|
|
763
|
+
// pubSubApp was explicitly cleared
|
|
764
|
+
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
|
|
718
765
|
}
|
|
719
766
|
|
|
720
767
|
let insertResult = await insertResultReq.exec();
|
|
721
768
|
|
|
722
|
-
let
|
|
723
|
-
if (
|
|
724
|
-
throw
|
|
769
|
+
let errorEntry = insertResult.find(entry => entry && entry[0]);
|
|
770
|
+
if (errorEntry) {
|
|
771
|
+
throw errorEntry[0];
|
|
725
772
|
}
|
|
726
773
|
|
|
727
774
|
if (opts.partial) {
|
|
@@ -739,17 +786,7 @@ class OAuth2AppsHandler {
|
|
|
739
786
|
updated: true
|
|
740
787
|
};
|
|
741
788
|
|
|
742
|
-
|
|
743
|
-
let appData = await this.get(id);
|
|
744
|
-
if (appData.baseScopes === 'pubsub') {
|
|
745
|
-
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
746
|
-
if (Object.keys(pubsubUpdates || {})) {
|
|
747
|
-
result.pubsubUpdates = pubsubUpdates;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
} catch (err) {
|
|
751
|
-
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
752
|
-
}
|
|
789
|
+
await this.tryEnsurePubsub(id, result);
|
|
753
790
|
|
|
754
791
|
return result;
|
|
755
792
|
}
|
|
@@ -762,148 +799,224 @@ class OAuth2AppsHandler {
|
|
|
762
799
|
|
|
763
800
|
let appData = await this.get(id);
|
|
764
801
|
|
|
765
|
-
if (appData
|
|
766
|
-
|
|
802
|
+
if (!appData) {
|
|
803
|
+
return { id, deleted: false, accounts: 0 };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Acquire the same lock used by ensurePubsub to prevent racing with recovery
|
|
807
|
+
let needsPubSubLock = appData.pubSubTopic || appData.baseScopes === 'pubsub';
|
|
808
|
+
let delLock;
|
|
809
|
+
|
|
810
|
+
if (needsPubSubLock) {
|
|
811
|
+
let lockKey = ['oauth', 'pubsub', appData.id].join(':');
|
|
767
812
|
try {
|
|
768
|
-
await
|
|
813
|
+
delLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
|
|
814
|
+
if (!delLock.success) {
|
|
815
|
+
throw new Error('Failed to get lock for pubsub app deletion');
|
|
816
|
+
}
|
|
769
817
|
} catch (err) {
|
|
770
|
-
logger.error({ msg: 'Failed to
|
|
818
|
+
logger.error({ msg: 'Failed to acquire lock for pubsub app deletion', lockKey, err });
|
|
819
|
+
throw err;
|
|
771
820
|
}
|
|
772
821
|
}
|
|
773
822
|
|
|
774
|
-
|
|
775
|
-
.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
823
|
+
try {
|
|
824
|
+
if (appData.pubSubTopic) {
|
|
825
|
+
// try to delete topic
|
|
826
|
+
try {
|
|
827
|
+
await this.deleteTopic(appData);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
logger.error({
|
|
830
|
+
msg: 'Failed to delete existing pubsub topic',
|
|
831
|
+
app: appData.id,
|
|
832
|
+
topic: appData.pubSubTopic,
|
|
833
|
+
subscription: appData.pubSubSubscription,
|
|
834
|
+
err
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
786
838
|
|
|
787
|
-
|
|
839
|
+
let pipeline = this.redis
|
|
840
|
+
.multi()
|
|
841
|
+
.srem(this.getIndexKey(), id)
|
|
842
|
+
.hdel(this.getDataKey(), [`${id}:data`, `${id}:meta`])
|
|
843
|
+
.scard(`${REDIS_PREFIX}oapp:a:${id}`)
|
|
844
|
+
.del(`${REDIS_PREFIX}oapp:a:${id}`)
|
|
845
|
+
.del(`${REDIS_PREFIX}oapp:h:${id}`)
|
|
846
|
+
.srem(this.getSubscribersKey(), id);
|
|
847
|
+
|
|
848
|
+
if (appData.pubSubApp) {
|
|
849
|
+
pipeline = pipeline.srem(this.getPubsubAppKey(appData.pubSubApp), id);
|
|
850
|
+
}
|
|
788
851
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
852
|
+
if (appData.baseScopes === 'pubsub') {
|
|
853
|
+
pipeline = pipeline.del(this.getPubsubAppKey(id));
|
|
854
|
+
}
|
|
793
855
|
|
|
794
|
-
|
|
856
|
+
let deleteResult = await pipeline.exec();
|
|
795
857
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
858
|
+
let errorEntry = deleteResult.find(entry => entry && entry[0]);
|
|
859
|
+
if (errorEntry) {
|
|
860
|
+
throw errorEntry[0];
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
|
|
864
|
+
|
|
865
|
+
return {
|
|
866
|
+
id,
|
|
867
|
+
deleted: deletedDocs >= 2,
|
|
868
|
+
accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
|
|
869
|
+
};
|
|
870
|
+
} finally {
|
|
871
|
+
if (delLock?.success) {
|
|
872
|
+
await lock.releaseLock(delLock);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
801
875
|
}
|
|
802
876
|
|
|
877
|
+
// Note: not atomic (read-modify-write). Concurrent callers use last-write-wins,
|
|
878
|
+
// which is acceptable for self-correcting informational metadata.
|
|
803
879
|
async setMeta(id, meta) {
|
|
804
|
-
let existingMeta;
|
|
805
880
|
let existingMetaBuf = await this.redis.hgetBuffer(this.getDataKey(), `${id}:meta`);
|
|
806
|
-
|
|
807
|
-
existingMeta = {};
|
|
808
|
-
} else {
|
|
809
|
-
existingMeta = msgpack.decode(existingMetaBuf);
|
|
810
|
-
}
|
|
881
|
+
let existingMeta = existingMetaBuf ? msgpack.decode(existingMetaBuf) : {};
|
|
811
882
|
|
|
812
883
|
let entry = Object.assign(existingMeta, meta || {});
|
|
813
884
|
|
|
814
|
-
|
|
885
|
+
await this.redis.hmset(this.getDataKey(), {
|
|
815
886
|
[`${id}:meta`]: msgpack.encode(entry)
|
|
816
|
-
};
|
|
817
|
-
|
|
818
|
-
await this.redis.hmset(this.getDataKey(), updates);
|
|
887
|
+
});
|
|
819
888
|
|
|
820
|
-
return {
|
|
821
|
-
id,
|
|
822
|
-
updated: true
|
|
823
|
-
};
|
|
889
|
+
return { id, updated: true };
|
|
824
890
|
}
|
|
825
891
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
837
|
-
if (!accessToken) {
|
|
838
|
-
throw new Error('Failed to get access token');
|
|
839
|
-
}
|
|
892
|
+
/**
|
|
893
|
+
* Fire-and-forget setMeta call that logs errors instead of throwing.
|
|
894
|
+
* Used in ensurePubsub error handlers where we want to record a flag
|
|
895
|
+
* but must not block or alter the original error flow.
|
|
896
|
+
*/
|
|
897
|
+
setMetaFireAndForget(appId, meta) {
|
|
898
|
+
this.setMeta(appId, meta).catch(metaErr => {
|
|
899
|
+
logger.error({ msg: 'Failed to set metadata', app: appId, err: metaErr });
|
|
900
|
+
});
|
|
901
|
+
}
|
|
840
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Try to set up Pub/Sub for the given app and attach results to the result object.
|
|
905
|
+
* Errors are logged but not thrown so they do not prevent create/update from succeeding.
|
|
906
|
+
*/
|
|
907
|
+
async tryEnsurePubsub(id, result) {
|
|
841
908
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
{}
|
|
847
|
-
*/
|
|
909
|
+
let appData = await this.get(id);
|
|
910
|
+
if (appData.baseScopes === 'pubsub') {
|
|
911
|
+
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
912
|
+
result.pubsubUpdates = pubsubUpdates || {};
|
|
848
913
|
}
|
|
849
914
|
} catch (err) {
|
|
850
|
-
|
|
851
|
-
case 403:
|
|
852
|
-
// no permissions
|
|
853
|
-
logger.error({
|
|
854
|
-
msg: 'Service client does not have permissions to delete Pub/Sub topics. Make sure the role for the service user is "Pub/Sub Admin".',
|
|
855
|
-
app: appData.id,
|
|
856
|
-
topic: topicName
|
|
857
|
-
});
|
|
858
|
-
throw err;
|
|
859
|
-
case 404: {
|
|
860
|
-
// does not exist
|
|
861
|
-
logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
|
|
862
|
-
break;
|
|
863
|
-
}
|
|
864
|
-
default:
|
|
865
|
-
throw err;
|
|
866
|
-
}
|
|
915
|
+
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
867
916
|
}
|
|
917
|
+
}
|
|
868
918
|
|
|
919
|
+
async _retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, reason) {
|
|
920
|
+
logger.warn({
|
|
921
|
+
msg: `${reason} deleting Pub/Sub ${resourceType}, retrying in ${delay}ms`,
|
|
922
|
+
app: appData.id,
|
|
923
|
+
[resourceType]: resourceName
|
|
924
|
+
});
|
|
925
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
869
926
|
try {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
927
|
+
await doDelete();
|
|
928
|
+
} catch (retryErr) {
|
|
929
|
+
if (retryErr?.oauthRequest?.response?.error?.code === 404) {
|
|
930
|
+
logger.info({
|
|
931
|
+
msg: `${resourceType} no longer exists after retry`,
|
|
932
|
+
app: appData.id,
|
|
933
|
+
[resourceType]: resourceName
|
|
934
|
+
});
|
|
935
|
+
return;
|
|
876
936
|
}
|
|
937
|
+
logger.error({
|
|
938
|
+
msg: `Retry failed deleting Pub/Sub ${resourceType}, resource may need manual cleanup`,
|
|
939
|
+
app: appData.id,
|
|
940
|
+
[resourceType]: resourceName,
|
|
941
|
+
code: retryErr.code || retryErr?.oauthRequest?.response?.error?.code
|
|
942
|
+
});
|
|
943
|
+
throw retryErr;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async _deletePubSubResource(client, accessToken, appData, resourceType, resourceName) {
|
|
948
|
+
if (!resourceName) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
let url = `https://pubsub.googleapis.com/v1/${resourceName}`;
|
|
952
|
+
const doDelete = () => client.request(accessToken, url, 'DELETE', Buffer.alloc(0), { returnText: true });
|
|
953
|
+
try {
|
|
954
|
+
await doDelete();
|
|
877
955
|
} catch (err) {
|
|
878
956
|
switch (err?.oauthRequest?.response?.error?.code) {
|
|
879
957
|
case 403:
|
|
880
|
-
// no permissions
|
|
881
958
|
logger.error({
|
|
882
|
-
msg:
|
|
959
|
+
msg: `Service client does not have permissions to delete Pub/Sub ${resourceType}s. Make sure the role for the service user is "Pub/Sub Admin".`,
|
|
883
960
|
app: appData.id,
|
|
884
|
-
|
|
961
|
+
[resourceType]: resourceName
|
|
885
962
|
});
|
|
886
963
|
throw err;
|
|
887
|
-
case 404:
|
|
888
|
-
|
|
889
|
-
|
|
964
|
+
case 404:
|
|
965
|
+
logger.info({ msg: `${resourceType} does not exist`, app: appData.id, [resourceType]: resourceName });
|
|
966
|
+
break;
|
|
967
|
+
case 429: {
|
|
968
|
+
let retryAfter = err.retryAfter || err.oauthRequest?.retryAfter;
|
|
969
|
+
let delay = retryAfter ? Math.min(retryAfter * 1000, 30000) : 2000;
|
|
970
|
+
await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, 'Rate limited');
|
|
890
971
|
break;
|
|
891
972
|
}
|
|
892
973
|
default:
|
|
974
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
975
|
+
await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, 2000, 'Transient network error');
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
893
978
|
throw err;
|
|
894
979
|
}
|
|
895
980
|
}
|
|
896
981
|
}
|
|
897
982
|
|
|
983
|
+
async deleteTopic(appData) {
|
|
984
|
+
let client = await this.getClient(appData.id);
|
|
985
|
+
|
|
986
|
+
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
987
|
+
if (!accessToken) {
|
|
988
|
+
throw new Error('Failed to get access token');
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
|
|
992
|
+
await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
|
|
993
|
+
}
|
|
994
|
+
|
|
898
995
|
async ensurePubsub(appData) {
|
|
899
996
|
let project = appData.googleProjectId;
|
|
900
997
|
|
|
901
998
|
let topic = appData.googleTopicName || `ee-pub-${appData.id}`;
|
|
902
999
|
let subscription = appData.googleSubscriptionName || `ee-sub-${appData.id}`;
|
|
903
1000
|
|
|
1001
|
+
// Determine desired expirationPolicy from setting
|
|
1002
|
+
let gmailSubscriptionTtl = await settings.get('gmailSubscriptionTtl');
|
|
1003
|
+
let desiredExpirationPolicy;
|
|
1004
|
+
if (gmailSubscriptionTtl === 0) {
|
|
1005
|
+
// Indefinite (no expiration)
|
|
1006
|
+
desiredExpirationPolicy = {};
|
|
1007
|
+
} else if (typeof gmailSubscriptionTtl === 'number' && gmailSubscriptionTtl > 0) {
|
|
1008
|
+
// Convert days to seconds for Google API
|
|
1009
|
+
desiredExpirationPolicy = { ttl: `${gmailSubscriptionTtl * 86400}s` };
|
|
1010
|
+
} else {
|
|
1011
|
+
// Not set - let Google apply its default 31-day TTL
|
|
1012
|
+
desiredExpirationPolicy = null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
904
1015
|
let results = {};
|
|
1016
|
+
let ttlPatchFailed = null;
|
|
905
1017
|
|
|
906
|
-
if (!project
|
|
1018
|
+
if (!project) {
|
|
1019
|
+
logger.warn({ msg: 'googleProjectId is required for Pub/Sub setup', app: appData.id });
|
|
907
1020
|
return results;
|
|
908
1021
|
}
|
|
909
1022
|
|
|
@@ -916,130 +1029,181 @@ class OAuth2AppsHandler {
|
|
|
916
1029
|
const member = 'serviceAccount:gmail-api-push@system.gserviceaccount.com';
|
|
917
1030
|
const role = 'roles/pubsub.publisher';
|
|
918
1031
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1032
|
+
// Acquire per-app lock to prevent concurrent ensurePubsub races (e.g., user update + background recovery)
|
|
1033
|
+
let lockKey = ['oauth', 'pubsub', appData.id].join(':');
|
|
1034
|
+
let ensureLock;
|
|
1035
|
+
try {
|
|
1036
|
+
ensureLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
|
|
1037
|
+
if (!ensureLock.success) {
|
|
1038
|
+
logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey });
|
|
1039
|
+
throw new Error('Failed to get ensurePubsub lock');
|
|
1040
|
+
}
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey, err });
|
|
1043
|
+
throw err;
|
|
925
1044
|
}
|
|
926
1045
|
|
|
927
|
-
// Step 2. Ensure topic
|
|
928
1046
|
try {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
} catch (err) {
|
|
936
|
-
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
937
|
-
logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
|
|
938
|
-
throw err;
|
|
1047
|
+
let client = await this.getClient(appData.id);
|
|
1048
|
+
|
|
1049
|
+
// Step 1. Get access token for service client
|
|
1050
|
+
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
1051
|
+
if (!accessToken) {
|
|
1052
|
+
throw new Error('Failed to get access token');
|
|
939
1053
|
}
|
|
940
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
941
|
-
case 403:
|
|
942
|
-
// no permissions
|
|
943
|
-
if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
|
|
944
|
-
this.setMeta(appData.id, {
|
|
945
|
-
authFlag: {
|
|
946
|
-
message:
|
|
947
|
-
'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
|
|
948
|
-
description: err?.oauthRequest?.response?.error?.message
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
} else {
|
|
952
|
-
this.setMeta(appData.id, {
|
|
953
|
-
authFlag: {
|
|
954
|
-
message: 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
1054
|
|
|
1055
|
+
// Step 2. Ensure topic
|
|
1056
|
+
try {
|
|
1057
|
+
// fails if topic does not exist
|
|
1058
|
+
await client.request(accessToken, topicUrl, 'GET');
|
|
1059
|
+
logger.debug({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
1060
|
+
/*
|
|
1061
|
+
{name: 'projects/...'}
|
|
1062
|
+
*/
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1065
|
+
logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
|
|
959
1066
|
throw err;
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1067
|
+
}
|
|
1068
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1069
|
+
case 403:
|
|
1070
|
+
// no permissions
|
|
1071
|
+
if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
|
|
1072
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1073
|
+
authFlag: {
|
|
1074
|
+
message:
|
|
1075
|
+
'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
|
|
1076
|
+
description: err?.oauthRequest?.response?.error?.message
|
|
1077
|
+
},
|
|
1078
|
+
ttlWarning: null
|
|
1079
|
+
});
|
|
1080
|
+
} else {
|
|
1081
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1082
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1083
|
+
ttlWarning: null
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
throw err;
|
|
1088
|
+
case 404: {
|
|
1089
|
+
// does not exist
|
|
1090
|
+
logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
|
|
1091
|
+
try {
|
|
1092
|
+
let topicCreateRes = await client.request(accessToken, topicUrl, 'PUT', Buffer.alloc(0));
|
|
1093
|
+
/*
|
|
966
1094
|
{name: 'projects/...'}
|
|
967
1095
|
*/
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1096
|
+
if (!topicCreateRes?.name) {
|
|
1097
|
+
throw new Error('Topic was not created', { cause: err });
|
|
1098
|
+
}
|
|
971
1099
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
throw err;
|
|
985
|
-
}
|
|
986
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
987
|
-
case 403:
|
|
988
|
-
// no permissions
|
|
989
|
-
this.setMeta(appData.id, {
|
|
990
|
-
authFlag: {
|
|
991
|
-
message:
|
|
992
|
-
'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
993
|
-
}
|
|
994
|
-
});
|
|
995
|
-
throw err;
|
|
996
|
-
case 409:
|
|
997
|
-
// already exists
|
|
998
|
-
logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
999
|
-
break;
|
|
1000
|
-
default:
|
|
1100
|
+
await this.update(
|
|
1101
|
+
appData.id,
|
|
1102
|
+
{
|
|
1103
|
+
pubSubTopic: topicName
|
|
1104
|
+
},
|
|
1105
|
+
{ partial: true }
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
results.pubSubTopic = topicName;
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1111
|
+
logger.warn({ msg: 'Network error creating Pub/Sub topic', app: appData.id, code: err.code });
|
|
1001
1112
|
throw err;
|
|
1113
|
+
}
|
|
1114
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1115
|
+
case 403:
|
|
1116
|
+
// no permissions
|
|
1117
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1118
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1119
|
+
ttlWarning: null
|
|
1120
|
+
});
|
|
1121
|
+
throw err;
|
|
1122
|
+
case 409:
|
|
1123
|
+
// already exists
|
|
1124
|
+
logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
1125
|
+
break;
|
|
1126
|
+
default:
|
|
1127
|
+
throw err;
|
|
1128
|
+
}
|
|
1002
1129
|
}
|
|
1130
|
+
break;
|
|
1003
1131
|
}
|
|
1004
|
-
|
|
1132
|
+
default:
|
|
1133
|
+
throw err;
|
|
1005
1134
|
}
|
|
1006
|
-
default:
|
|
1007
|
-
throw err;
|
|
1008
1135
|
}
|
|
1009
|
-
}
|
|
1010
1136
|
|
|
1011
|
-
|
|
1137
|
+
// Step 3. Set up subscriber
|
|
1012
1138
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1139
|
+
try {
|
|
1140
|
+
// fails if topic does not exist
|
|
1141
|
+
let subscriptionData = await client.request(accessToken, subscriptionUrl, 'GET');
|
|
1142
|
+
logger.debug({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1143
|
+
/*
|
|
1018
1144
|
{name: 'projects/...'}
|
|
1019
1145
|
*/
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
// no permissions
|
|
1028
|
-
this.setMeta(appData.id, {
|
|
1029
|
-
authFlag: {
|
|
1030
|
-
message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1031
|
-
}
|
|
1032
|
-
});
|
|
1033
|
-
throw err;
|
|
1034
|
-
case 404: {
|
|
1035
|
-
// does not exist
|
|
1036
|
-
logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
|
|
1146
|
+
|
|
1147
|
+
// Patch existing subscription's expiration policy if it differs from desired
|
|
1148
|
+
// When no explicit TTL is configured, use Google's default (31 days) for comparison
|
|
1149
|
+
let effectivePolicy = desiredExpirationPolicy ?? { ttl: GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL };
|
|
1150
|
+
let currentTtl = subscriptionData?.expirationPolicy?.ttl || null;
|
|
1151
|
+
let desiredTtl = effectivePolicy.ttl || null;
|
|
1152
|
+
if (currentTtl !== desiredTtl) {
|
|
1037
1153
|
try {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1154
|
+
await client.request(accessToken, subscriptionUrl, 'PATCH', {
|
|
1155
|
+
subscription: { expirationPolicy: effectivePolicy },
|
|
1156
|
+
updateMask: 'expirationPolicy'
|
|
1041
1157
|
});
|
|
1042
|
-
|
|
1158
|
+
logger.info({
|
|
1159
|
+
msg: 'Updated expiration policy on subscription',
|
|
1160
|
+
app: appData.id,
|
|
1161
|
+
subscription: subscriptionName,
|
|
1162
|
+
desiredTtl
|
|
1163
|
+
});
|
|
1164
|
+
// Update stored TTL after successful patch
|
|
1165
|
+
subscriptionData.expirationPolicy = effectivePolicy;
|
|
1166
|
+
} catch (patchErr) {
|
|
1167
|
+
ttlPatchFailed = patchErr;
|
|
1168
|
+
logger.warn({
|
|
1169
|
+
msg: 'Failed to update expiration policy on subscription',
|
|
1170
|
+
app: appData.id,
|
|
1171
|
+
subscription: subscriptionName,
|
|
1172
|
+
err: patchErr
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Store current expiration info in meta
|
|
1178
|
+
await this.setMeta(appData.id, {
|
|
1179
|
+
subscriptionExpiration: subscriptionData?.expirationPolicy?.ttl || null
|
|
1180
|
+
});
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1183
|
+
logger.warn({ msg: 'Network error checking Pub/Sub subscription', app: appData.id, code: err.code });
|
|
1184
|
+
throw err;
|
|
1185
|
+
}
|
|
1186
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1187
|
+
case 403:
|
|
1188
|
+
// no permissions
|
|
1189
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1190
|
+
authFlag: { message: PUBSUB_PERM_VIEW },
|
|
1191
|
+
ttlWarning: null
|
|
1192
|
+
});
|
|
1193
|
+
throw err;
|
|
1194
|
+
case 404: {
|
|
1195
|
+
// does not exist
|
|
1196
|
+
logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
|
|
1197
|
+
try {
|
|
1198
|
+
let createPayload = {
|
|
1199
|
+
topic: topicName,
|
|
1200
|
+
ackDeadlineSeconds: GMAIL_PUBSUB_ACK_DEADLINE_SECONDS
|
|
1201
|
+
};
|
|
1202
|
+
if (desiredExpirationPolicy !== null) {
|
|
1203
|
+
createPayload.expirationPolicy = desiredExpirationPolicy;
|
|
1204
|
+
}
|
|
1205
|
+
let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', createPayload);
|
|
1206
|
+
/*
|
|
1043
1207
|
{
|
|
1044
1208
|
name: 'projects/webhooks-425411/subscriptions/ee-sub-AAABkE9_uNMAAAAH',
|
|
1045
1209
|
topic: 'projects/webhooks-425411/topics/ee-pub-AAABkE9_uNMAAAAH',
|
|
@@ -1051,57 +1215,61 @@ class OAuth2AppsHandler {
|
|
|
1051
1215
|
}
|
|
1052
1216
|
*/
|
|
1053
1217
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1218
|
+
if (!subscriptionCreateRes?.name) {
|
|
1219
|
+
throw new Error('Subscription was not created', { cause: err });
|
|
1220
|
+
}
|
|
1057
1221
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
this.setMeta(appData.id, {
|
|
1076
|
-
authFlag: {
|
|
1077
|
-
message:
|
|
1078
|
-
'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1079
|
-
}
|
|
1080
|
-
});
|
|
1081
|
-
throw err;
|
|
1082
|
-
case 409:
|
|
1083
|
-
// already exists
|
|
1084
|
-
logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1085
|
-
break;
|
|
1086
|
-
default:
|
|
1222
|
+
await this.update(
|
|
1223
|
+
appData.id,
|
|
1224
|
+
{
|
|
1225
|
+
pubSubSubscription: subscriptionName
|
|
1226
|
+
},
|
|
1227
|
+
{ partial: true }
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
results.pubSubSubscription = subscriptionName;
|
|
1231
|
+
|
|
1232
|
+
// Store expiration info from the created subscription
|
|
1233
|
+
await this.setMeta(appData.id, {
|
|
1234
|
+
subscriptionExpiration: subscriptionCreateRes?.expirationPolicy?.ttl || null
|
|
1235
|
+
});
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1238
|
+
logger.warn({ msg: 'Network error creating Pub/Sub subscription', app: appData.id, code: err.code });
|
|
1087
1239
|
throw err;
|
|
1240
|
+
}
|
|
1241
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1242
|
+
case 403:
|
|
1243
|
+
// no permissions
|
|
1244
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1245
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1246
|
+
ttlWarning: null
|
|
1247
|
+
});
|
|
1248
|
+
throw err;
|
|
1249
|
+
case 409:
|
|
1250
|
+
// already exists
|
|
1251
|
+
logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1252
|
+
break;
|
|
1253
|
+
default:
|
|
1254
|
+
throw err;
|
|
1255
|
+
}
|
|
1088
1256
|
}
|
|
1257
|
+
break;
|
|
1089
1258
|
}
|
|
1090
|
-
|
|
1259
|
+
default:
|
|
1260
|
+
throw err;
|
|
1091
1261
|
}
|
|
1092
|
-
default:
|
|
1093
|
-
throw err;
|
|
1094
1262
|
}
|
|
1095
|
-
}
|
|
1096
1263
|
|
|
1097
|
-
|
|
1264
|
+
// Step 4. Grant access to Gmail publisher
|
|
1098
1265
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1266
|
+
let existingPolicy;
|
|
1267
|
+
let getIamPolicyRes;
|
|
1268
|
+
try {
|
|
1269
|
+
// Check for an existing policy grant
|
|
1270
|
+
getIamPolicyRes = await client.request(accessToken, `${topicUrl}:getIamPolicy`, 'GET');
|
|
1271
|
+
existingPolicy = getIamPolicyRes?.bindings?.find(binding => binding?.role === role && binding?.members?.includes(member));
|
|
1272
|
+
/*
|
|
1105
1273
|
{
|
|
1106
1274
|
version: 1,
|
|
1107
1275
|
etag: 'BwYbtWmb5c0=',
|
|
@@ -1113,77 +1281,93 @@ class OAuth2AppsHandler {
|
|
|
1113
1281
|
]
|
|
1114
1282
|
}
|
|
1115
1283
|
*/
|
|
1116
|
-
} catch (err) {
|
|
1117
|
-
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1118
|
-
logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1119
|
-
throw err;
|
|
1120
|
-
}
|
|
1121
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1122
|
-
case 403:
|
|
1123
|
-
// no permissions
|
|
1124
|
-
this.setMeta(appData.id, {
|
|
1125
|
-
authFlag: {
|
|
1126
|
-
message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1127
|
-
}
|
|
1128
|
-
});
|
|
1129
|
-
throw err;
|
|
1130
|
-
default:
|
|
1131
|
-
throw err;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
if (existingPolicy) {
|
|
1136
|
-
logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
|
|
1137
|
-
} else {
|
|
1138
|
-
logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
|
|
1139
|
-
const policyPayload = {
|
|
1140
|
-
policy: {
|
|
1141
|
-
bindings: [
|
|
1142
|
-
{
|
|
1143
|
-
members: [member],
|
|
1144
|
-
role
|
|
1145
|
-
}
|
|
1146
|
-
]
|
|
1147
|
-
}
|
|
1148
|
-
};
|
|
1149
|
-
try {
|
|
1150
|
-
await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
|
|
1151
|
-
results.iamPolicy = {
|
|
1152
|
-
members: [member],
|
|
1153
|
-
role
|
|
1154
|
-
};
|
|
1155
|
-
|
|
1156
|
-
await this.update(
|
|
1157
|
-
appData.id,
|
|
1158
|
-
{
|
|
1159
|
-
pubSubIamPolicy: results.iamPolicy
|
|
1160
|
-
},
|
|
1161
|
-
{ partial: true }
|
|
1162
|
-
);
|
|
1163
1284
|
} catch (err) {
|
|
1164
1285
|
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1165
|
-
logger.warn({ msg: 'Network error
|
|
1286
|
+
logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1166
1287
|
throw err;
|
|
1167
1288
|
}
|
|
1168
1289
|
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1169
1290
|
case 403:
|
|
1170
1291
|
// no permissions
|
|
1171
|
-
this.
|
|
1172
|
-
authFlag: {
|
|
1173
|
-
|
|
1174
|
-
}
|
|
1292
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1293
|
+
authFlag: { message: PUBSUB_PERM_VIEW },
|
|
1294
|
+
ttlWarning: null
|
|
1175
1295
|
});
|
|
1176
1296
|
throw err;
|
|
1177
1297
|
default:
|
|
1178
1298
|
throw err;
|
|
1179
1299
|
}
|
|
1180
1300
|
}
|
|
1181
|
-
}
|
|
1182
1301
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1302
|
+
if (existingPolicy) {
|
|
1303
|
+
logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
|
|
1304
|
+
} else {
|
|
1305
|
+
logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
|
|
1306
|
+
let existingBindings = (getIamPolicyRes && getIamPolicyRes.bindings) || [];
|
|
1307
|
+
const policyPayload = {
|
|
1308
|
+
policy: {
|
|
1309
|
+
bindings: existingBindings.concat([
|
|
1310
|
+
{
|
|
1311
|
+
members: [member],
|
|
1312
|
+
role
|
|
1313
|
+
}
|
|
1314
|
+
])
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
if (getIamPolicyRes && getIamPolicyRes.etag) {
|
|
1318
|
+
policyPayload.policy.etag = getIamPolicyRes.etag;
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
|
|
1322
|
+
results.iamPolicy = {
|
|
1323
|
+
members: [member],
|
|
1324
|
+
role
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
await this.update(
|
|
1328
|
+
appData.id,
|
|
1329
|
+
{
|
|
1330
|
+
pubSubIamPolicy: results.iamPolicy
|
|
1331
|
+
},
|
|
1332
|
+
{ partial: true }
|
|
1333
|
+
);
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1336
|
+
logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1337
|
+
throw err;
|
|
1338
|
+
}
|
|
1339
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1340
|
+
case 403:
|
|
1341
|
+
// no permissions
|
|
1342
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1343
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1344
|
+
ttlWarning: null
|
|
1345
|
+
});
|
|
1346
|
+
throw err;
|
|
1347
|
+
default:
|
|
1348
|
+
throw err;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1185
1352
|
|
|
1186
|
-
|
|
1353
|
+
// clear auth flag if everything worked; set or clear TTL warning
|
|
1354
|
+
let metaUpdate = { authFlag: null };
|
|
1355
|
+
if (ttlPatchFailed) {
|
|
1356
|
+
metaUpdate.ttlWarning = {
|
|
1357
|
+
message: 'Failed to update expiration policy on subscription',
|
|
1358
|
+
description: [ttlPatchFailed.message, ttlPatchFailed.code].filter(Boolean).join('; ')
|
|
1359
|
+
};
|
|
1360
|
+
} else {
|
|
1361
|
+
metaUpdate.ttlWarning = null;
|
|
1362
|
+
}
|
|
1363
|
+
await this.setMeta(appData.id, metaUpdate);
|
|
1364
|
+
|
|
1365
|
+
return results;
|
|
1366
|
+
} finally {
|
|
1367
|
+
if (ensureLock?.success) {
|
|
1368
|
+
await lock.releaseLock(ensureLock);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1187
1371
|
}
|
|
1188
1372
|
|
|
1189
1373
|
async getClient(id, extraOpts) {
|
|
@@ -1202,7 +1386,7 @@ class OAuth2AppsHandler {
|
|
|
1202
1386
|
let redirectUrl = appData.redirectUrl;
|
|
1203
1387
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
|
|
1204
1388
|
|
|
1205
|
-
let googleProjectId = appData.
|
|
1389
|
+
let googleProjectId = appData.googleProjectId;
|
|
1206
1390
|
let workspaceAccounts = appData.googleWorkspaceAccounts;
|
|
1207
1391
|
|
|
1208
1392
|
if (!clientId || !clientSecret || !redirectUrl) {
|
|
@@ -1245,7 +1429,7 @@ class OAuth2AppsHandler {
|
|
|
1245
1429
|
|
|
1246
1430
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
|
|
1247
1431
|
|
|
1248
|
-
let googleProjectId = appData.
|
|
1432
|
+
let googleProjectId = appData.googleProjectId;
|
|
1249
1433
|
let workspaceAccounts = appData.googleWorkspaceAccounts;
|
|
1250
1434
|
|
|
1251
1435
|
if (!serviceClient || !serviceKey) {
|
|
@@ -1291,7 +1475,7 @@ class OAuth2AppsHandler {
|
|
|
1291
1475
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, outlookScopes(cloud), appData.skipScopes);
|
|
1292
1476
|
|
|
1293
1477
|
if (!clientId || !clientSecret || !authority || !redirectUrl) {
|
|
1294
|
-
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook'), { statusCode: 400 });
|
|
1478
|
+
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (delegated)'), { statusCode: 400 });
|
|
1295
1479
|
throw error;
|
|
1296
1480
|
}
|
|
1297
1481
|
|
|
@@ -1322,6 +1506,41 @@ class OAuth2AppsHandler {
|
|
|
1322
1506
|
);
|
|
1323
1507
|
}
|
|
1324
1508
|
|
|
1509
|
+
case 'outlookService': {
|
|
1510
|
+
let authority = appData.authority;
|
|
1511
|
+
let clientId = appData.clientId;
|
|
1512
|
+
let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
|
|
1513
|
+
|
|
1514
|
+
let cloud = appData.cloud || 'global';
|
|
1515
|
+
|
|
1516
|
+
if (!clientId || !clientSecret || !authority) {
|
|
1517
|
+
let error = Boom.boomify(new Error('OAuth2 credentials not set up for Outlook (application)'), { statusCode: 400 });
|
|
1518
|
+
throw error;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Client credentials use {apiBase}/.default scope; per-scope config is not applicable
|
|
1522
|
+
return new OutlookOauth(
|
|
1523
|
+
Object.assign(
|
|
1524
|
+
{
|
|
1525
|
+
authority,
|
|
1526
|
+
clientId,
|
|
1527
|
+
clientSecret,
|
|
1528
|
+
cloud,
|
|
1529
|
+
provider: 'outlookService',
|
|
1530
|
+
useClientCredentials: true,
|
|
1531
|
+
setFlag: async flag => {
|
|
1532
|
+
try {
|
|
1533
|
+
await this.setMeta(id, { authFlag: flag });
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'outlookService', err });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
},
|
|
1539
|
+
extraOpts
|
|
1540
|
+
)
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1325
1544
|
case 'mailRu': {
|
|
1326
1545
|
let clientId = appData.clientId;
|
|
1327
1546
|
let clientSecret = appData.clientSecret ? await this.decrypt(appData.clientSecret) : null;
|
|
@@ -1411,14 +1630,14 @@ class OAuth2AppsHandler {
|
|
|
1411
1630
|
let { access_token: accessToken, expires_in: expiresIn } = await client.refreshToken({ isPrincipal });
|
|
1412
1631
|
let expires = new Date(now + expiresIn * 1000);
|
|
1413
1632
|
if (!accessToken) {
|
|
1414
|
-
recordTokenMetric('failure', '
|
|
1633
|
+
recordTokenMetric('failure', appData.provider || 'unknown', '0');
|
|
1415
1634
|
return null;
|
|
1416
1635
|
}
|
|
1417
1636
|
|
|
1418
1637
|
logger.debug({ msg: 'Renewed access token for service account', app: appData.id, isPrincipal });
|
|
1419
1638
|
|
|
1420
1639
|
// Record successful token refresh
|
|
1421
|
-
recordTokenMetric('success', '
|
|
1640
|
+
recordTokenMetric('success', appData.provider || 'unknown', '200');
|
|
1422
1641
|
|
|
1423
1642
|
await this.update(
|
|
1424
1643
|
appData.id,
|
|
@@ -1434,7 +1653,7 @@ class OAuth2AppsHandler {
|
|
|
1434
1653
|
} catch (err) {
|
|
1435
1654
|
// Record failed token refresh
|
|
1436
1655
|
const statusCode = err.statusCode || err.tokenRequest?.status || 0;
|
|
1437
|
-
recordTokenMetric('failure', '
|
|
1656
|
+
recordTokenMetric('failure', appData.provider || 'unknown', statusCode);
|
|
1438
1657
|
|
|
1439
1658
|
logger.info({
|
|
1440
1659
|
msg: 'Failed to renew OAuth2 access token',
|
|
@@ -1445,15 +1664,27 @@ class OAuth2AppsHandler {
|
|
|
1445
1664
|
});
|
|
1446
1665
|
throw err;
|
|
1447
1666
|
} finally {
|
|
1448
|
-
|
|
1667
|
+
if (renewLock?.success) {
|
|
1668
|
+
await lock.releaseLock(renewLock);
|
|
1669
|
+
}
|
|
1449
1670
|
}
|
|
1450
1671
|
}
|
|
1451
1672
|
}
|
|
1452
1673
|
|
|
1674
|
+
/**
|
|
1675
|
+
* Returns true if the given OAuth2 app uses API-based access (Graph API, Gmail API)
|
|
1676
|
+
* rather than IMAP/SMTP. Service account providers always use API access.
|
|
1677
|
+
*/
|
|
1678
|
+
function isApiBasedApp(app) {
|
|
1679
|
+
return app && (app.baseScopes === 'api' || SERVICE_ACCOUNT_PROVIDERS.has(app.provider));
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1453
1682
|
module.exports = {
|
|
1454
1683
|
oauth2Apps: new OAuth2AppsHandler({ redis }),
|
|
1455
1684
|
OAUTH_PROVIDERS,
|
|
1685
|
+
SERVICE_ACCOUNT_PROVIDERS,
|
|
1456
1686
|
LEGACY_KEYS,
|
|
1457
1687
|
oauth2ProviderData,
|
|
1458
|
-
formatExtraScopes
|
|
1688
|
+
formatExtraScopes,
|
|
1689
|
+
isApiBasedApp
|
|
1459
1690
|
};
|