emailengine-app 2.67.3 → 2.68.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.
@@ -6,6 +6,48 @@ const crypto = require('crypto');
6
6
 
7
7
  const { fetch: fetchCmd } = require('undici');
8
8
 
9
+ const { ExternalAccountSigner } = require('./external-account-signer');
10
+
11
+ const SERVICE_AUTH_METHODS = new Set(['serviceKey', 'externalAccount']);
12
+
13
+ class LocalKeySigner {
14
+ constructor(opts) {
15
+ opts = opts || {};
16
+ this.serviceKey = opts.serviceKey;
17
+ }
18
+
19
+ async sign(jwtPayload, signOptions) {
20
+ if (!this.serviceKey) {
21
+ let err = new Error('Service account private key is not configured');
22
+ err.code = 'EServiceKeyMissing';
23
+ throw err;
24
+ }
25
+ let kid = signOptions && signOptions.kid;
26
+ const header = kid ? `{"alg":"RS256","typ":"JWT","kid":"${kid}"}` : `{"alg":"RS256","typ":"JWT"}`;
27
+ const encodedPayload = [header, JSON.stringify(jwtPayload)].map(val => Buffer.from(val).toString('base64url')).join('.');
28
+ const signature = crypto.createSign('RSA-SHA256').update(encodedPayload).sign(this.serviceKey);
29
+ return [encodedPayload, Buffer.from(signature).toString('base64url')].join('.');
30
+ }
31
+ }
32
+
33
+ function parseExternalAccountConfig(externalAccount) {
34
+ if (externalAccount && typeof externalAccount === 'object') {
35
+ return externalAccount;
36
+ }
37
+ if (typeof externalAccount !== 'string' || !externalAccount.trim()) {
38
+ let err = new Error('External account configuration is empty');
39
+ err.code = 'EExternalAccountConfig';
40
+ throw err;
41
+ }
42
+ try {
43
+ return JSON.parse(externalAccount);
44
+ } catch (parseErr) {
45
+ let err = new Error(`External account configuration is not valid JSON: ${parseErr.message}`);
46
+ err.code = 'EExternalAccountConfig';
47
+ throw err;
48
+ }
49
+ }
50
+
9
51
  const GMAIL_SCOPES = {
10
52
  imap: ['https://mail.google.com/'],
11
53
  api: ['https://www.googleapis.com/auth/gmail.modify'],
@@ -206,6 +248,51 @@ class GmailOauth {
206
248
 
207
249
  this.tokenUrl = `https://oauth2.googleapis.com/token`;
208
250
  this.revokeUrl = `https://oauth2.googleapis.com/revoke`;
251
+
252
+ // Explicit authMethod wins; otherwise default by which credential is present.
253
+ let authMethod = opts.authMethod || (opts.externalAccount ? 'externalAccount' : 'serviceKey');
254
+ if (!SERVICE_AUTH_METHODS.has(authMethod)) {
255
+ let err = new Error(`Unknown service account authMethod: ${authMethod}`);
256
+ err.code = 'EUnknownAuthMethod';
257
+ throw err;
258
+ }
259
+ this.authMethod = authMethod;
260
+
261
+ // Only build a signer when this is a service-account-mode app.
262
+ // 3-legged OAuth has no serviceClient and never reaches generateServiceRequest.
263
+ if (this.serviceClient) {
264
+ if (this.authMethod === 'externalAccount') {
265
+ this.signer = new ExternalAccountSigner({
266
+ config: parseExternalAccountConfig(opts.externalAccount),
267
+ logger: this.logger,
268
+ logRaw: this.logRaw
269
+ });
270
+
271
+ // With Workload Identity Federation the JWT is signed by the
272
+ // impersonated service account (derived from the external account
273
+ // config), not by an uploaded private key. Google validates the
274
+ // jwt-bearer assertion by resolving the service account from the
275
+ // `iss` claim and checking the signature against that account's
276
+ // key, so `iss` MUST match the signing identity. We therefore
277
+ // issue assertions as the signer's service account email rather
278
+ // than relying on the separately entered serviceClient field.
279
+ this.serviceAccountEmail = this.signer.serviceAccountEmail;
280
+
281
+ // Catch the misconfiguration where the operator points the
282
+ // impersonation URL at one service account but enters a different
283
+ // service client email - this would otherwise surface as an opaque
284
+ // invalid_grant only at refresh time.
285
+ if (this.serviceClientEmail && this.serviceClientEmail !== this.serviceAccountEmail) {
286
+ let err = new Error(
287
+ `Service client email (${this.serviceClientEmail}) does not match the service account in the external account configuration (${this.serviceAccountEmail})`
288
+ );
289
+ err.code = 'EServiceAccountMismatch';
290
+ throw err;
291
+ }
292
+ } else {
293
+ this.signer = new LocalKeySigner({ serviceKey: this.serviceKey });
294
+ }
295
+ }
209
296
  }
210
297
 
211
298
  generateAuthUrl(opts) {
@@ -358,11 +445,11 @@ class GmailOauth {
358
445
  async refreshToken(opts) {
359
446
  opts = opts || {};
360
447
 
361
- const url = new URL(`https://oauth2.googleapis.com/token`);
448
+ const url = new URL(this.tokenUrl);
362
449
 
363
450
  if (this.serviceClient) {
364
451
  // refresh using JWT
365
- let requestData = this.generateServiceRequest(opts.user, opts.isPrincipal);
452
+ let requestData = await this.generateServiceRequest(opts.user, opts.isPrincipal);
366
453
  for (let key of Object.keys(requestData.payload)) {
367
454
  url.searchParams.set(key, requestData.payload[key]);
368
455
  }
@@ -438,6 +525,7 @@ class GmailOauth {
438
525
  status: res.status,
439
526
  clientId: this.clientId,
440
527
  serviceClient: this.serviceClient,
528
+ signer: this.serviceClient ? this.authMethod : undefined,
441
529
  googleProjectId: this.googleProjectId,
442
530
  serviceClientEmail: this.serviceClientEmail,
443
531
  scopes: this.scopes
@@ -640,19 +728,33 @@ class GmailOauth {
640
728
  return result;
641
729
  }
642
730
 
643
- generateServiceRequest(principal, isPrincipal) {
731
+ async generateServiceRequest(principal, isPrincipal) {
732
+ if (!this.signer) {
733
+ let err = new Error('Service account credentials are not configured');
734
+ err.code = 'EServiceCredentialsMissing';
735
+ throw err;
736
+ }
737
+
644
738
  let iat = Math.floor(Date.now() / 1000); // unix time
645
739
 
740
+ // In Workload Identity Federation mode the assertion is signed by the
741
+ // service account behind serviceAccountEmail, so the issuer must be that
742
+ // same account. In local-key mode the issuer follows the historical
743
+ // behaviour (numeric serviceClient for delegation, serviceClientEmail for
744
+ // the principal grant), which is inherently consistent with the uploaded key.
745
+ let principalIssuer = this.authMethod === 'externalAccount' && this.serviceAccountEmail ? this.serviceAccountEmail : this.serviceClientEmail;
746
+ let delegatedIssuer = this.authMethod === 'externalAccount' && this.serviceAccountEmail ? this.serviceAccountEmail : this.serviceClient;
747
+
646
748
  let tokenData = isPrincipal
647
749
  ? {
648
- iss: this.serviceClientEmail,
750
+ iss: principalIssuer,
649
751
  scope: this.scopes.join(' '),
650
752
  aud: this.tokenUrl,
651
753
  iat,
652
754
  exp: iat + 3600
653
755
  }
654
756
  : {
655
- iss: this.serviceClient,
757
+ iss: delegatedIssuer,
656
758
  scope: this.scopes.join(' '),
657
759
  sub: principal,
658
760
  aud: this.tokenUrl,
@@ -660,7 +762,9 @@ class GmailOauth {
660
762
  exp: iat + 3600
661
763
  };
662
764
 
663
- let token = this.jwtSignRS256(tokenData, isPrincipal);
765
+ // Preserves prior behaviour: kid is only set in principal mode for local-key signing.
766
+ // ExternalAccountSigner ignores the kid (Google generates the JWT header server-side).
767
+ let token = await this.signer.sign(tokenData, { kid: isPrincipal ? this.serviceClient : null });
664
768
 
665
769
  return {
666
770
  tokenData,
@@ -671,14 +775,6 @@ class GmailOauth {
671
775
  };
672
776
  }
673
777
 
674
- jwtSignRS256(payload, useKid) {
675
- const encodedPayload = [`{"alg":"RS256","typ":"JWT"${useKid ? `,"kid":"${this.serviceClient}"` : ''}}`, JSON.stringify(payload)]
676
- .map(val => Buffer.from(val).toString('base64url'))
677
- .join('.');
678
- const signature = crypto.createSign('RSA-SHA256').update(encodedPayload).sign(this.serviceKey);
679
- return [encodedPayload, Buffer.from(signature).toString('base64url')].join('.');
680
- }
681
-
682
778
  async revokeToken(token) {
683
779
  const fetchOpts = {
684
780
  method: 'post',
@@ -731,3 +827,6 @@ module.exports.GmailOauth = GmailOauth;
731
827
  module.exports.GMAIL_SCOPES = GMAIL_SCOPES;
732
828
  module.exports.GMAIL_API_SCOPES = GMAIL_API_SCOPES;
733
829
  module.exports.OPENID_SCOPES = OPENID_SCOPES;
830
+ module.exports.LocalKeySigner = LocalKeySigner;
831
+ module.exports.parseExternalAccountConfig = parseExternalAccountConfig;
832
+ module.exports.SERVICE_AUTH_METHODS = SERVICE_AUTH_METHODS;
@@ -0,0 +1,397 @@
1
+ 'use strict';
2
+
3
+ // OAuth2 app "Verify setup" diagnostic. Runs the real authentication chain for a
4
+ // configured OAuth2 app step by step against the provider and returns a structured
5
+ // result so the admin can see exactly which step fails and how to fix it. Reuses the
6
+ // existing OAuth clients and the coded errors they already throw - no provider logic
7
+ // is reimplemented here.
8
+
9
+ const { ImapFlow } = require('imapflow');
10
+ const { oauth2Apps, oauth2ProviderData, SERVICE_ACCOUNT_PROVIDERS } = require('../oauth2-apps');
11
+ const packageData = require('../../package.json');
12
+
13
+ // A single diagnostic step. status: 'ok' | 'fail' | 'skip'.
14
+ const mkStep = (id, label, status, message, extra) => Object.assign({ id, label, status, message: message || null }, extra || {});
15
+
16
+ const finalize = (appData, authMethod, steps, account) => ({
17
+ app: appData.id,
18
+ provider: appData.provider,
19
+ authMethod: authMethod || null,
20
+ account: account || null,
21
+ ok: steps.every(s => s.status !== 'fail'),
22
+ steps
23
+ });
24
+
25
+ // Bound the live IMAP probe so a hung connection cannot stall the request.
26
+ const withTimeout = (promise, ms, label) =>
27
+ Promise.race([
28
+ promise,
29
+ new Promise((resolve, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms).unref())
30
+ ]);
31
+
32
+ function describeResponse(err) {
33
+ let resp = err && (err.response || (err.tokenRequest && err.tokenRequest.response));
34
+ if (resp && typeof resp === 'object') {
35
+ // Google API errors nest the detail under error.message; OAuth/STS errors
36
+ // use top-level string error/error_description fields.
37
+ if (resp.error && typeof resp.error === 'object' && resp.error.message) {
38
+ return resp.error.message;
39
+ }
40
+ let parts = [resp.error, resp.error_description].filter(Boolean);
41
+ if (parts.length) {
42
+ return parts.join(': ');
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // Maps the signer's coded errors (ESubjectTokenRead / ESTSExchange / ESignJwt) onto
49
+ // discrete steps, marking everything up to the failure as ok.
50
+ function mapSigningError(err, authMethod, steps) {
51
+ let detail = describeResponse(err);
52
+ let msg = m => [m, detail].filter(Boolean).join(' - ');
53
+
54
+ if (authMethod === 'externalAccount') {
55
+ switch (err.code) {
56
+ case 'ESubjectTokenRead':
57
+ steps.push(
58
+ mkStep('subjectToken', 'Read OIDC subject token', 'fail', err.message, {
59
+ hint: 'EmailEngine could not read the OIDC subject token from the configured credential source on this host. Ensure the file path or URL in the external account configuration exists and is readable by the EmailEngine process.'
60
+ })
61
+ );
62
+ return;
63
+ case 'ESTSExchange':
64
+ steps.push(mkStep('subjectToken', 'Read OIDC subject token', 'ok', 'Subject token read from the credential source'));
65
+ steps.push(
66
+ mkStep('sts', 'STS token exchange', 'fail', msg('Google STS rejected the token exchange'), {
67
+ hint: "Verify the Workload Identity Pool provider's issuer URI, allowed audience and attribute mapping, and that the subject token's audience matches the provider."
68
+ })
69
+ );
70
+ return;
71
+ case 'ESignJwt':
72
+ steps.push(mkStep('subjectToken', 'Read OIDC subject token', 'ok', 'Subject token read from the credential source'));
73
+ steps.push(mkStep('sts', 'STS token exchange', 'ok', 'Federated access token obtained from Google STS'));
74
+ steps.push(
75
+ mkStep('signJwt', 'Sign assertion (signJwt)', 'fail', msg('Google IAM rejected signJwt'), {
76
+ hint: 'The federated identity is not allowed to sign JWTs as this service account. Grant it roles/iam.serviceAccountTokenCreator on the target service account. Note: roles/iam.workloadIdentityUser is NOT sufficient - it allows generateAccessToken but not signJwt.'
77
+ })
78
+ );
79
+ return;
80
+ default:
81
+ steps.push(mkStep('signJwt', 'Sign assertion (Workload Identity Federation)', 'fail', msg(err.message)));
82
+ return;
83
+ }
84
+ }
85
+
86
+ // serviceKey mode: a single local signing step
87
+ steps.push(
88
+ mkStep('sign', 'Sign assertion with service key', 'fail', err.message, {
89
+ hint: 'The service account private key could not sign the assertion. Re-upload the JSON key file for this service account.'
90
+ })
91
+ );
92
+ }
93
+
94
+ // Maps GmailOauth.refreshToken (jwt-bearer) failures, using checkForFlags codes.
95
+ function mapTokenError(err, account, appData, steps) {
96
+ let flag = err.tokenRequest && err.tokenRequest.flag;
97
+ let detail = describeResponse(err) || err.message;
98
+ let scopeList = (appData.baseScopes === 'api' && 'https://www.googleapis.com/auth/gmail.modify') || 'https://mail.google.com/';
99
+
100
+ let hint;
101
+ switch (flag && flag.code) {
102
+ case 'UNAUTHORIZED_CLIENT':
103
+ hint = `Authorize domain-wide delegation in Google Workspace Admin (Security > API controls > Domain-wide delegation): add client ID ${appData.serviceClient} with the OAuth scope ${scopeList}.`;
104
+ break;
105
+ case 'INVALID_CLIENT_EMAIL':
106
+ case 'INVALID_SERVICE_CLIENT_EMAIL':
107
+ hint = 'The service account principal is invalid or unrecognized. Verify the service account email and client ID in the app settings.';
108
+ break;
109
+ case 'INSUFFICIENT_AUTH_SCOPES':
110
+ hint = `The scopes authorized for domain-wide delegation do not cover the requested scope (${scopeList}). Update the DWD authorization in Workspace Admin.`;
111
+ break;
112
+ case 'GMAIL_API_NOT_ENABLED':
113
+ hint = `Enable the Gmail API for this Google Cloud project${flag.url ? `: ${flag.url}` : '.'}`;
114
+ break;
115
+ default:
116
+ hint = `Could not obtain a token for ${account}. Ensure the address exists in the Workspace domain and that domain-wide delegation is authorized for client ID ${appData.serviceClient} with scope ${scopeList}.`;
117
+ }
118
+
119
+ steps.push(mkStep('token', 'Domain-wide delegation token', 'fail', detail, { hint }));
120
+ }
121
+
122
+ // Live, read-only IMAP XOAUTH2 login. No mailbox changes, no mail sent.
123
+ async function imapProbe(appData, account, accessToken, steps) {
124
+ let imapCfg = oauth2ProviderData(appData.provider, appData.cloud).imap;
125
+ let client = new ImapFlow({
126
+ host: imapCfg.host,
127
+ port: imapCfg.port,
128
+ secure: imapCfg.secure,
129
+ auth: { user: account, accessToken },
130
+ logger: false,
131
+ emitLogs: false,
132
+ clientInfo: { name: packageData.name, version: packageData.version }
133
+ });
134
+ client.on('error', () => {});
135
+ try {
136
+ await withTimeout(client.connect(), 25 * 1000, 'IMAP connection');
137
+ let mailboxes = await withTimeout(client.list(), 15 * 1000, 'IMAP folder listing');
138
+ steps.push(mkStep('mailbox', 'Mailbox access (IMAP)', 'ok', `Connected to ${imapCfg.host} as ${account}; ${mailboxes.length} folders visible`));
139
+ } catch (err) {
140
+ let authFailed =
141
+ err.authenticationFailed || /AUTHENTICATIONFAILED|Invalid credentials|authenticationfailed/i.test(err.responseText || err.message || '');
142
+ steps.push(
143
+ mkStep('mailbox', 'Mailbox access (IMAP)', 'fail', err.responseText || err.message, {
144
+ hint: authFailed
145
+ ? 'A token was obtained but the IMAP login was rejected. Ensure IMAP access is enabled for the domain/user in Google Workspace (Apps > Gmail > end-user access) and that the OAuth scope includes https://mail.google.com/.'
146
+ : `Could not reach ${imapCfg.host}. Check network egress and TLS to the mail server.`
147
+ })
148
+ );
149
+ } finally {
150
+ // Best-effort cleanup; must never throw out of finally and mask the result.
151
+ try {
152
+ await client.logout();
153
+ } catch (err) {
154
+ try {
155
+ client.close();
156
+ } catch (err2) {
157
+ // ignore
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ async function verifyGmailService(appData, opts, steps) {
164
+ let { account, testConnection } = opts;
165
+ let authMethod = appData.authMethod === 'externalAccount' ? 'externalAccount' : 'serviceKey';
166
+
167
+ // Step 1 - configuration. getClient builds the signer, which validates the external
168
+ // account JSON and the service-account-email match (or fails if creds are missing).
169
+ let client;
170
+ try {
171
+ client = await oauth2Apps.getClient(appData.id, { setFlag: async () => {} });
172
+ steps.push(
173
+ mkStep(
174
+ 'config',
175
+ 'Configuration',
176
+ 'ok',
177
+ authMethod === 'externalAccount' ? 'External account configuration is valid' : 'Service account key is present'
178
+ )
179
+ );
180
+ } catch (err) {
181
+ steps.push(
182
+ mkStep('config', 'Configuration', 'fail', err.message, {
183
+ hint: 'Complete the service account credentials in the app settings (service client, email and key/external account configuration).'
184
+ })
185
+ );
186
+ return finalize(appData, authMethod, steps, account);
187
+ }
188
+
189
+ // Step 2 - signing chain (no mailbox/email needed; signJwt signs an arbitrary payload).
190
+ try {
191
+ await client.generateServiceRequest(account || 'verify-probe@example.com', false);
192
+ if (authMethod === 'externalAccount') {
193
+ steps.push(mkStep('subjectToken', 'Read OIDC subject token', 'ok', 'Subject token read from the credential source'));
194
+ steps.push(mkStep('sts', 'STS token exchange', 'ok', 'Federated access token obtained from Google STS'));
195
+ steps.push(mkStep('signJwt', 'Sign assertion (signJwt)', 'ok', 'Assertion signed via IAM signJwt'));
196
+ } else {
197
+ steps.push(mkStep('sign', 'Sign assertion with service key', 'ok', 'Assertion signed locally with the service account key'));
198
+ }
199
+ } catch (err) {
200
+ mapSigningError(err, authMethod, steps);
201
+ return finalize(appData, authMethod, steps, account);
202
+ }
203
+
204
+ // Pub/Sub apps authenticate as the service account itself (principal mode), not by
205
+ // impersonating a user, so they need no mailbox address and no domain-wide delegation.
206
+ if (appData.baseScopes === 'pubsub') {
207
+ try {
208
+ let resp = await client.refreshToken({ isPrincipal: true });
209
+ if (!resp || !resp.access_token) {
210
+ throw Object.assign(new Error('No access token returned by the token endpoint'), { code: 'ETokenRefresh' });
211
+ }
212
+ steps.push(mkStep('token', 'Service account token', 'ok', 'App-only access token obtained for the service account'));
213
+ } catch (err) {
214
+ let flag = err.tokenRequest && err.tokenRequest.flag;
215
+ let hint =
216
+ flag && flag.code === 'INVALID_SERVICE_CLIENT_EMAIL'
217
+ ? 'The service account is invalid or unrecognized. Verify the service account email and client ID in the app settings.'
218
+ : 'The service account could not obtain its own access token. Verify the service account email and client ID in the app settings.';
219
+ steps.push(mkStep('token', 'Service account token', 'fail', describeResponse(err) || err.message, { hint }));
220
+ }
221
+ return finalize(appData, authMethod, steps, account);
222
+ }
223
+
224
+ // Step 3 - domain-wide delegation token (needs a Workspace email to impersonate).
225
+ if (!account) {
226
+ steps.push(
227
+ mkStep('token', 'Domain-wide delegation token', 'skip', 'Provide a Workspace email address to verify domain-wide delegation and mailbox access')
228
+ );
229
+ return finalize(appData, authMethod, steps, account);
230
+ }
231
+
232
+ let accessToken;
233
+ try {
234
+ let resp = await client.refreshToken({ user: account });
235
+ accessToken = resp && resp.access_token;
236
+ steps.push(mkStep('token', 'Domain-wide delegation token', 'ok', `Access token obtained for ${account}`));
237
+ } catch (err) {
238
+ mapTokenError(err, account, appData, steps);
239
+ return finalize(appData, authMethod, steps, account);
240
+ }
241
+
242
+ if (!accessToken) {
243
+ steps.push(mkStep('mailbox', 'Mailbox access', 'fail', 'No access token returned by the token endpoint'));
244
+ return finalize(appData, authMethod, steps, account);
245
+ }
246
+
247
+ // Step 4 - live mailbox access.
248
+ if (!testConnection) {
249
+ steps.push(mkStep('mailbox', 'Mailbox access', 'skip', 'Connection test disabled'));
250
+ } else if (appData.baseScopes === 'api') {
251
+ try {
252
+ let profile = await client.request(accessToken, 'https://gmail.googleapis.com/gmail/v1/users/me/profile', 'get');
253
+ steps.push(
254
+ mkStep(
255
+ 'mailbox',
256
+ 'Gmail API access',
257
+ 'ok',
258
+ `Gmail API reachable for ${(profile && profile.emailAddress) || account} (${(profile && profile.messagesTotal) || 0} messages)`
259
+ )
260
+ );
261
+ } catch (err) {
262
+ steps.push(
263
+ mkStep('mailbox', 'Gmail API access', 'fail', describeResponse(err) || err.message, {
264
+ hint: 'Ensure the Gmail API is enabled for the project and the configured scope grants Gmail API access (e.g. gmail.modify or gmail.readonly).'
265
+ })
266
+ );
267
+ }
268
+ } else {
269
+ await imapProbe(appData, account, accessToken, steps);
270
+ }
271
+
272
+ return finalize(appData, authMethod, steps, account);
273
+ }
274
+
275
+ async function verifyOutlookService(appData, opts, steps) {
276
+ let { account, testConnection } = opts;
277
+
278
+ let client;
279
+ try {
280
+ client = await oauth2Apps.getClient(appData.id, { setFlag: async () => {} });
281
+ steps.push(mkStep('config', 'Configuration', 'ok', 'Client credentials configuration is present'));
282
+ } catch (err) {
283
+ steps.push(
284
+ mkStep('config', 'Configuration', 'fail', err.message, {
285
+ hint: 'Complete the client ID, client secret and tenant (authority) in the app settings.'
286
+ })
287
+ );
288
+ return finalize(appData, 'clientCredentials', steps, account);
289
+ }
290
+
291
+ let accessToken;
292
+ try {
293
+ let resp = await client.refreshToken({});
294
+ accessToken = resp && resp.access_token;
295
+ steps.push(mkStep('token', 'Client credentials token', 'ok', 'App-only access token obtained from Microsoft Entra'));
296
+ } catch (err) {
297
+ steps.push(
298
+ mkStep('token', 'Client credentials token', 'fail', describeResponse(err) || err.message, {
299
+ hint: 'Microsoft Entra rejected the client credentials grant. Verify the tenant ID, client ID and secret, and that admin consent has been granted for the application.'
300
+ })
301
+ );
302
+ return finalize(appData, 'clientCredentials', steps, account);
303
+ }
304
+
305
+ if (!account) {
306
+ steps.push(mkStep('mailbox', 'Mailbox access', 'skip', 'Provide a mailbox address to verify application access to a mailbox'));
307
+ return finalize(appData, 'clientCredentials', steps, account);
308
+ }
309
+ if (!testConnection || !accessToken) {
310
+ steps.push(mkStep('mailbox', 'Mailbox access', 'skip', testConnection ? 'No access token returned' : 'Connection test disabled'));
311
+ return finalize(appData, 'clientCredentials', steps, account);
312
+ }
313
+
314
+ // outlookService is API-based: probe Microsoft Graph (app-only), matching how the
315
+ // real client accesses the mailbox - not IMAP.
316
+ try {
317
+ let url = `${client.apiBase}/v1.0/users/${encodeURIComponent(account)}?$select=id,mail,userPrincipalName`;
318
+ let user = await client.request(accessToken, url, 'get');
319
+ steps.push(
320
+ mkStep('mailbox', 'Mailbox access (Microsoft Graph)', 'ok', `Graph reachable for ${(user && (user.mail || user.userPrincipalName)) || account}`)
321
+ );
322
+ } catch (err) {
323
+ let status = err.statusCode;
324
+ let hint =
325
+ status === 403
326
+ ? 'The application lacks the required Graph permission or admin consent. Grant application permissions (e.g. Mail.ReadWrite) and admin consent in Microsoft Entra.'
327
+ : status === 404
328
+ ? `Mailbox ${account} was not found in the tenant.`
329
+ : 'Microsoft Graph request failed. Verify the application permissions and that the mailbox exists.';
330
+ steps.push(mkStep('mailbox', 'Mailbox access (Microsoft Graph)', 'fail', describeResponse(err) || err.message, { hint }));
331
+ }
332
+ return finalize(appData, 'clientCredentials', steps, account);
333
+ }
334
+
335
+ // 3-legged interactive OAuth apps cannot be verified without a user authorization.
336
+ function verifyInteractive(appData, steps) {
337
+ let configured = !!(appData.clientId && appData.clientSecret && appData.redirectUrl);
338
+
339
+ steps.push(
340
+ mkStep(
341
+ 'config',
342
+ 'Client configuration',
343
+ configured ? 'ok' : 'fail',
344
+ configured ? 'Client ID, client secret and redirect URL are set' : 'Missing one of: client ID, client secret, redirect URL',
345
+ {
346
+ hint: configured ? undefined : 'Fill in the client ID, client secret and redirect URL in the app settings.'
347
+ }
348
+ )
349
+ );
350
+ steps.push(
351
+ mkStep('interactive', 'End-user authorization', 'skip', 'This is an interactive (3-legged) OAuth2 application', {
352
+ hint: 'Connect a test email account using this application to fully verify the configuration - the authorization, scopes and token exchange are validated when a user grants access.'
353
+ })
354
+ );
355
+ return finalize(appData, null, steps, null);
356
+ }
357
+
358
+ /**
359
+ * Verify the setup of a configured OAuth2 application.
360
+ * @param {String} appId - OAuth2 application id
361
+ * @param {Object} [opts]
362
+ * @param {String} [opts.account] - email/mailbox address used to verify delegation and mailbox access
363
+ * @param {Boolean} [opts.testConnection=true] - perform the live IMAP/API connection step
364
+ * @returns {Object} { app, provider, authMethod, account, ok, steps[] }
365
+ */
366
+ async function verifyOAuth2App(appId, opts) {
367
+ opts = opts || {};
368
+ let account = opts.account || null;
369
+ let testConnection = opts.testConnection !== false;
370
+
371
+ let appData = await oauth2Apps.get(appId);
372
+ if (!appData) {
373
+ let err = new Error('OAuth2 application was not found');
374
+ err.code = 'AppNotFound';
375
+ err.statusCode = 404;
376
+ throw err;
377
+ }
378
+
379
+ let steps = [];
380
+ let runOpts = { account, testConnection };
381
+
382
+ if (appData.provider === 'gmailService') {
383
+ return await verifyGmailService(appData, runOpts, steps);
384
+ }
385
+ if (appData.provider === 'outlookService') {
386
+ return await verifyOutlookService(appData, runOpts, steps);
387
+ }
388
+ if (SERVICE_ACCOUNT_PROVIDERS.has(appData.provider)) {
389
+ // Future service-account providers: fall back to a config-only check.
390
+ let authMethod = appData.authMethod || null;
391
+ steps.push(mkStep('config', 'Configuration', 'skip', `Automated verification is not implemented for provider "${appData.provider}"`));
392
+ return finalize(appData, authMethod, steps, account);
393
+ }
394
+ return verifyInteractive(appData, steps);
395
+ }
396
+
397
+ module.exports = { verifyOAuth2App };