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.
@@ -88,3 +88,6 @@ jobs:
88
88
  GMAIL_SERVICE_POSTALSYS_SERVICE_EMAIL: ${{ secrets.GMAIL_SERVICE_POSTALSYS_SERVICE_EMAIL }}
89
89
  GMAIL_SERVICE_POSTALSYS_KEY: ${{ secrets.GMAIL_SERVICE_POSTALSYS_KEY }}
90
90
  GMAIL_SERVICE_POSTALSYS_ACCOUNT_EMAIL: ${{ secrets.GMAIL_SERVICE_POSTALSYS_ACCOUNT_EMAIL }}
91
+ GMAIL_WIF_SA_KEY: ${{ secrets.GMAIL_WIF_SA_KEY }}
92
+ GMAIL_WIF_AUDIENCE: ${{ secrets.GMAIL_WIF_AUDIENCE }}
93
+ GMAIL_WIF_ACCOUNT_EMAIL: ${{ secrets.GMAIL_WIF_ACCOUNT_EMAIL }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.68.0](https://github.com/postalsys/emailengine/compare/v2.67.3...v2.68.0) (2026-05-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * accept threadId on the submit reference object ([b1b3dc7](https://github.com/postalsys/emailengine/commit/b1b3dc73ec5870a7a8ab9f0844055822ad046d2a))
9
+ * add direct account registration for email service account apps ([6602b2e](https://github.com/postalsys/emailengine/commit/6602b2ee3f851bcfec00b34e70756007763f5490))
10
+ * add OAuth2 app "Verify setup" diagnostic ([cba9f58](https://github.com/postalsys/emailengine/commit/cba9f58bc58a434c6156cd2e55fee655336a4cf4))
11
+ * add Workload Identity Federation for Gmail service accounts ([1879c5a](https://github.com/postalsys/emailengine/commit/1879c5aaf30ccff1879e74d28b0c76e25b3d77ae))
12
+ * lock Gmail auth method display after app creation ([6ba16c2](https://github.com/postalsys/emailengine/commit/6ba16c2fbca571d500b21be776dbf5eb76fcf8a1))
13
+ * revoke upstream OAuth2 grant on account delete via ?revoke=true ([2a8c8fa](https://github.com/postalsys/emailengine/commit/2a8c8faf7f63acfab31998fb067289d4c6bd2f09))
14
+ * revoke upstream OAuth2 grant on account delete via ?revoke=true ([15bc43a](https://github.com/postalsys/emailengine/commit/15bc43ae0f8dbf70021a1e13de55c80ec7271a15))
15
+ * use tabs for Gmail auth method and lock it after creation ([6c74521](https://github.com/postalsys/emailengine/commit/6c7452138c257ecf0667aa9b44d1fa91186e2302))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * clearer error when loading wrong file into WIF config field ([40686d4](https://github.com/postalsys/emailengine/commit/40686d409229347193c1e153e9e47f98b5de86dc))
21
+ * flag WIF support in the gmailService form intro ([61fab27](https://github.com/postalsys/emailengine/commit/61fab2720aa2f289a944ae99629bc47ada8e9e09))
22
+ * force-exit external-account-signer test to unblock CI ([f4a1e4e](https://github.com/postalsys/emailengine/commit/f4a1e4e50ac890d7771e38181a9488837c67757a))
23
+ * let npm run gettext parse object spread and drop non-ASCII from template ([195ce60](https://github.com/postalsys/emailengine/commit/195ce601db50f394164ca05e2efb6de4b784a77a))
24
+ * mask externalAccount in OAuth2 app API responses ([fac18bf](https://github.com/postalsys/emailengine/commit/fac18bfdc11d5d252e0d93980aad7c90d92b246f))
25
+ * pkg config options must be a string array ([6c4702d](https://github.com/postalsys/emailengine/commit/6c4702dbc85171a2987b8cfb6e5a3dc3d656a99d))
26
+ * prefer refresh token and skip gmailService when revoking on delete ([e0dbea8](https://github.com/postalsys/emailengine/commit/e0dbea8a4cd4804bf64d50523c8a1152c2119e26))
27
+ * relax cross-folder assertion in Graph API parentFolderId test ([770279d](https://github.com/postalsys/emailengine/commit/770279ded669efdc5d6a2c33d05290daf91251ea))
28
+ * right-align the service account Add account button ([c6f77b9](https://github.com/postalsys/emailengine/commit/c6f77b9777fb1cb9b1449662d05d4887e36a6ad8))
29
+
3
30
  ## [2.67.3](https://github.com/postalsys/emailengine/compare/v2.67.2...v2.67.3) (2026-04-21)
4
31
 
5
32
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2026-04-16T14:45:56.000000",
2
+ "creationTime": "2026-05-25T14:45:59.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
package/lib/account.js CHANGED
@@ -977,9 +977,32 @@ class Account {
977
977
  return { account: this.account, state };
978
978
  }
979
979
 
980
- async delete() {
980
+ async delete(opts) {
981
+ opts = opts || {};
982
+
981
983
  let accountData = await this.loadAccountData(this.account);
982
984
 
985
+ if (opts.revoke && accountData.oauth2?.provider) {
986
+ // Prefer refresh token over access token: revoking either invalidates the grant per Google's docs,
987
+ // but the stored access token may be expired (a 400 from the provider would leave the grant intact),
988
+ // while the refresh token is long-lived. Skip gmailService - Workspace service accounts have no
989
+ // per-user grant to revoke.
990
+ let token = accountData.oauth2.refreshToken || accountData.oauth2.accessToken;
991
+ if (token) {
992
+ try {
993
+ let oauth2App = await oauth2Apps.get(accountData.oauth2.provider);
994
+ if (oauth2App && oauth2App.provider !== 'gmailService') {
995
+ let oauthClient = await oauth2Apps.getClient(accountData.oauth2.provider, { logger: this.logger });
996
+ if (oauthClient && typeof oauthClient.revokeToken === 'function') {
997
+ await oauthClient.revokeToken(token);
998
+ }
999
+ }
1000
+ } catch (err) {
1001
+ this.logger.warn({ msg: 'Failed to revoke OAuth2 grant before account delete', account: this.account, err });
1002
+ }
1003
+ }
1004
+ }
1005
+
983
1006
  const dateKeyTdy = new Date().toISOString().substring(0, 10).replace(/-/g, '');
984
1007
  const dateKeyYdy = new Date(Date.now() - 24 * 3600 * 1000).toISOString().substring(0, 10).replace(/-/g, '');
985
1008
 
@@ -530,7 +530,7 @@ async function init(args) {
530
530
  });
531
531
 
532
532
  try {
533
- return await accountObject.delete();
533
+ return await accountObject.delete({ revoke: request.query.revoke });
534
534
  } catch (err) {
535
535
  request.logger.error({ msg: 'API request failed', err });
536
536
  if (Boom.isBoom(err)) {
@@ -545,7 +545,7 @@ async function init(args) {
545
545
  },
546
546
  options: {
547
547
  description: 'Remove account',
548
- notes: "Stop processing and clear the account's cache",
548
+ notes: "Stop processing and clear the account's cache. Pass revoke=true to also attempt revocation of the upstream OAuth2 grant at the provider before the account is removed.",
549
549
 
550
550
  tags: ['api', 'Account'],
551
551
 
@@ -567,6 +567,16 @@ async function init(args) {
567
567
 
568
568
  params: Joi.object({
569
569
  account: accountIdSchema.required()
570
+ }),
571
+
572
+ query: Joi.object({
573
+ revoke: Joi.boolean()
574
+ .truthy('Y', 'true', '1')
575
+ .falsy('N', 'false', 0)
576
+ .default(false)
577
+ .description(
578
+ 'If true, EmailEngine attempts to revoke the upstream OAuth2 grant at the provider before deleting the account. Currently supported for individual Gmail OAuth grants. For Gmail Workspace service-account integrations (gmailService), Outlook, and non-OAuth2 accounts the flag is a no-op. Revoke failures are logged and do not block deletion.'
579
+ )
570
580
  })
571
581
  },
572
582
 
@@ -1687,8 +1687,8 @@ class BaseClient {
1687
1687
  data.reference.update = true;
1688
1688
  }
1689
1689
 
1690
- // Preserve thread ID
1691
- if (referencedMessage.threadId) {
1690
+ // Preserve thread ID, but let a caller-supplied threadId win
1691
+ if (!data.reference.threadId && referencedMessage.threadId) {
1692
1692
  data.reference.threadId = referencedMessage.threadId;
1693
1693
  }
1694
1694
 
@@ -1906,15 +1906,18 @@ class BaseClient {
1906
1906
  messageId
1907
1907
  };
1908
1908
 
1909
- if (data.reference && data.reference.message) {
1910
- response.reference = {
1911
- message: data.reference.message,
1912
- documentStore: documentStoreUsed,
1913
- success: referencedMessage ? true : false
1914
- };
1915
-
1916
- if (!referencedMessage) {
1917
- response.reference.error = 'Referenced message was not found';
1909
+ if (data.reference && (data.reference.message || data.reference.threadId)) {
1910
+ response.reference = {};
1911
+ if (data.reference.message) {
1912
+ response.reference.message = data.reference.message;
1913
+ response.reference.documentStore = documentStoreUsed;
1914
+ response.reference.success = referencedMessage ? true : false;
1915
+ if (!referencedMessage) {
1916
+ response.reference.error = 'Referenced message was not found';
1917
+ }
1918
+ }
1919
+ if (data.reference.threadId) {
1920
+ response.reference.threadId = data.reference.threadId;
1918
1921
  }
1919
1922
  }
1920
1923
 
@@ -2069,15 +2072,18 @@ class BaseClient {
2069
2072
  queueId
2070
2073
  };
2071
2074
 
2072
- if (data.reference && data.reference.message) {
2073
- response.reference = {
2074
- message: data.reference.message,
2075
- documentStore: documentStoreUsed,
2076
- success: referencedMessage ? true : false
2077
- };
2078
-
2079
- if (!referencedMessage) {
2080
- response.reference.error = 'Referenced message was not found';
2075
+ if (data.reference && (data.reference.message || data.reference.threadId)) {
2076
+ response.reference = {};
2077
+ if (data.reference.message) {
2078
+ response.reference.message = data.reference.message;
2079
+ response.reference.documentStore = documentStoreUsed;
2080
+ response.reference.success = referencedMessage ? true : false;
2081
+ if (!referencedMessage) {
2082
+ response.reference.error = 'Referenced message was not found';
2083
+ }
2084
+ }
2085
+ if (data.reference.threadId) {
2086
+ response.reference.threadId = data.reference.threadId;
2081
2087
  }
2082
2088
  }
2083
2089
 
@@ -1502,9 +1502,8 @@ class GmailClient extends BaseClient {
1502
1502
  raw: raw.toString('base64url')
1503
1503
  };
1504
1504
 
1505
- // Maintain thread if replying
1506
- if (referencedMessage?.threadId) {
1507
- payload.threadId = referencedMessage.threadId;
1505
+ if (data.reference?.threadId) {
1506
+ payload.threadId = data.reference.threadId;
1508
1507
  }
1509
1508
 
1510
1509
  let uploadInfo;
@@ -1533,15 +1532,18 @@ class GmailClient extends BaseClient {
1533
1532
  messageId
1534
1533
  };
1535
1534
 
1536
- if (data.reference && data.reference.message) {
1537
- response.reference = {
1538
- message: data.reference.message,
1539
- documentStore: documentStoreUsed,
1540
- success: referencedMessage ? true : false
1541
- };
1542
-
1543
- if (!referencedMessage) {
1544
- response.reference.error = 'Referenced message was not found';
1535
+ if (data.reference && (data.reference.message || data.reference.threadId)) {
1536
+ response.reference = {};
1537
+ if (data.reference.message) {
1538
+ response.reference.message = data.reference.message;
1539
+ response.reference.documentStore = documentStoreUsed;
1540
+ response.reference.success = referencedMessage ? true : false;
1541
+ if (!referencedMessage) {
1542
+ response.reference.error = 'Referenced message was not found';
1543
+ }
1544
+ }
1545
+ if (data.reference.threadId) {
1546
+ response.reference.threadId = data.reference.threadId;
1545
1547
  }
1546
1548
  }
1547
1549
 
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ // Pure configuration helpers for Google external_account (Workload Identity
4
+ // Federation) credentials. This module intentionally has NO heavy dependencies
5
+ // (no Redis, no undici, no settings) so it can be imported from request
6
+ // validation (lib/schemas.js) as well as the runtime signer without dragging in
7
+ // the database layer.
8
+
9
+ const ACCEPTED_SUBJECT_TOKEN_TYPES = new Set(['urn:ietf:params:oauth:token-type:jwt', 'urn:ietf:params:oauth:token-type:id_token']);
10
+ const IMPERSONATION_URL_RE = /\/v1\/projects\/-\/serviceAccounts\/([^/]+):generateAccessToken$/;
11
+
12
+ function makeError(message, code, statusCode, extra) {
13
+ let err = new Error(message);
14
+ err.code = code;
15
+ if (statusCode) {
16
+ err.statusCode = statusCode;
17
+ }
18
+ if (extra && typeof extra === 'object') {
19
+ Object.assign(err, extra);
20
+ }
21
+ return err;
22
+ }
23
+
24
+ // Validates the structural shape of an external_account credential config and
25
+ // returns the derived target service account email. Throws an error tagged with
26
+ // code 'EExternalAccountConfig' on any problem so callers can surface a clean
27
+ // validation message instead of a late runtime failure.
28
+ function validateConfig(config) {
29
+ if (!config || typeof config !== 'object') {
30
+ throw makeError('External account configuration must be a JSON object', 'EExternalAccountConfig');
31
+ }
32
+
33
+ if (config.type !== 'external_account') {
34
+ throw makeError(`External account configuration must have type "external_account" (got ${JSON.stringify(config.type)})`, 'EExternalAccountConfig');
35
+ }
36
+
37
+ for (let key of ['audience', 'subject_token_type', 'token_url', 'service_account_impersonation_url']) {
38
+ let value = config[key];
39
+ if (typeof value !== 'string' || !value) {
40
+ throw makeError(`External account configuration is missing required string field "${key}"`, 'EExternalAccountConfig');
41
+ }
42
+ }
43
+
44
+ if (!ACCEPTED_SUBJECT_TOKEN_TYPES.has(config.subject_token_type)) {
45
+ throw makeError(
46
+ `External account subject_token_type ${JSON.stringify(config.subject_token_type)} is not supported. ` +
47
+ `Supported types: ${Array.from(ACCEPTED_SUBJECT_TOKEN_TYPES).join(', ')}.`,
48
+ 'EExternalAccountConfig'
49
+ );
50
+ }
51
+
52
+ let impersonationMatch = IMPERSONATION_URL_RE.exec(config.service_account_impersonation_url);
53
+ if (!impersonationMatch) {
54
+ throw makeError(
55
+ 'External account service_account_impersonation_url must point at iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{EMAIL}:generateAccessToken',
56
+ 'EExternalAccountConfig'
57
+ );
58
+ }
59
+ let targetServiceAccountEmail = decodeURIComponent(impersonationMatch[1]);
60
+
61
+ let source = config.credential_source;
62
+ if (!source || typeof source !== 'object') {
63
+ throw makeError('External account configuration is missing credential_source', 'EExternalAccountConfig');
64
+ }
65
+
66
+ let hasFile = typeof source.file === 'string' && source.file;
67
+ let hasUrl = typeof source.url === 'string' && source.url;
68
+ let hasExecutable = source.executable && typeof source.executable === 'object';
69
+ let hasEnvironmentId = typeof source.environment_id === 'string' && source.environment_id;
70
+
71
+ if (hasExecutable) {
72
+ throw makeError('credential_source.executable is not supported by EmailEngine', 'EExternalAccountConfig');
73
+ }
74
+ if (hasEnvironmentId) {
75
+ throw makeError(
76
+ `credential_source.environment_id (${source.environment_id}) is not supported by EmailEngine. Use a file or url credential source.`,
77
+ 'EExternalAccountConfig'
78
+ );
79
+ }
80
+ if (hasFile && hasUrl) {
81
+ throw makeError('credential_source must specify either "file" or "url", not both', 'EExternalAccountConfig');
82
+ }
83
+ if (!hasFile && !hasUrl) {
84
+ throw makeError('credential_source must specify a "file" or "url" field', 'EExternalAccountConfig');
85
+ }
86
+
87
+ if (source.format && typeof source.format === 'object') {
88
+ let formatType = source.format.type;
89
+ if (formatType && formatType !== 'text' && formatType !== 'json') {
90
+ throw makeError(`credential_source.format.type must be "text" or "json" (got ${JSON.stringify(formatType)})`, 'EExternalAccountConfig');
91
+ }
92
+ if (formatType === 'json' && (typeof source.format.subject_token_field_name !== 'string' || !source.format.subject_token_field_name)) {
93
+ throw makeError('credential_source.format.subject_token_field_name is required when format.type is "json"', 'EExternalAccountConfig');
94
+ }
95
+ }
96
+
97
+ return { targetServiceAccountEmail };
98
+ }
99
+
100
+ // Extracts the subject token string from a raw credential-source payload,
101
+ // honouring the optional text/json format descriptor.
102
+ function extractFromFormat(rawText, format) {
103
+ let formatType = (format && format.type) || 'text';
104
+ if (formatType === 'text') {
105
+ let trimmed = rawText.trim();
106
+ if (!trimmed) {
107
+ throw makeError('Subject token source returned an empty value', 'ESubjectTokenRead');
108
+ }
109
+ return trimmed;
110
+ }
111
+
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(rawText);
115
+ } catch (err) {
116
+ throw makeError(`Subject token source did not return valid JSON: ${err.message}`, 'ESubjectTokenRead');
117
+ }
118
+ let field = format.subject_token_field_name;
119
+ let value = parsed && typeof parsed === 'object' ? parsed[field] : undefined;
120
+ if (typeof value !== 'string' || !value.trim()) {
121
+ throw makeError(`Subject token JSON did not contain a non-empty string at field "${field}"`, 'ESubjectTokenRead');
122
+ }
123
+ return value.trim();
124
+ }
125
+
126
+ module.exports = {
127
+ ACCEPTED_SUBJECT_TOKEN_TYPES,
128
+ IMPERSONATION_URL_RE,
129
+ makeError,
130
+ validateConfig,
131
+ extractFromFormat
132
+ };
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { fetch: undiciFetch } = require('undici');
5
+ const packageData = require('../../package.json');
6
+ const { httpAgent } = require('../tools');
7
+ const { makeError, validateConfig, extractFromFormat } = require('./external-account-config');
8
+
9
+ const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
10
+ const REQUESTED_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
11
+ const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
12
+ const DEFAULT_IAM_CREDENTIALS_BASE_URL = 'https://iamcredentials.googleapis.com';
13
+ const SIGN_JWT_URL_TEMPLATE = '/v1/projects/-/serviceAccounts/{email}:signJwt';
14
+ const TOKEN_EXPIRY_SKEW_MS = 60 * 1000;
15
+ const DEFAULT_FEDERATED_TOKEN_TTL_MS = 3600 * 1000;
16
+ const USER_AGENT = `${packageData.name}/${packageData.version} (+${packageData.homepage})`;
17
+
18
+ class ExternalAccountSigner {
19
+ constructor(opts) {
20
+ opts = opts || {};
21
+
22
+ let { targetServiceAccountEmail } = validateConfig(opts.config);
23
+
24
+ this.config = opts.config;
25
+ // The service account whose Google-managed key signs the JWT. Exposed so
26
+ // callers can keep the JWT `iss` claim consistent with the signing identity.
27
+ this.serviceAccountEmail = targetServiceAccountEmail;
28
+ this.logger = opts.logger || null;
29
+ this.logRaw = !!opts.logRaw;
30
+
31
+ // Injection points for tests; production code uses undici/fs/Date as defaults.
32
+ this._fetch = opts.fetchImpl || undiciFetch;
33
+ this._readFile = opts.readFileImpl || fs.promises.readFile;
34
+ this._now = opts.nowImpl || (() => Date.now());
35
+
36
+ // Allow overriding the IAM Credentials base URL so integration tests
37
+ // (and unusual forward-proxy deployments) can redirect signJwt traffic.
38
+ this._iamCredentialsBaseUrl = (opts.iamCredentialsBaseUrl || DEFAULT_IAM_CREDENTIALS_BASE_URL).replace(/\/+$/, '');
39
+
40
+ this._cachedToken = null;
41
+ this._cachedTokenExpiresAt = 0;
42
+ this._pendingRefresh = null;
43
+ }
44
+
45
+ describeCredentialSource() {
46
+ let source = this.config.credential_source;
47
+ if (source.file) {
48
+ return { type: 'file', location: source.file };
49
+ }
50
+ return { type: 'url', location: source.url };
51
+ }
52
+
53
+ async _readSubjectToken() {
54
+ let source = this.config.credential_source;
55
+ let format = source.format || { type: 'text' };
56
+
57
+ if (source.file) {
58
+ let rawText;
59
+ try {
60
+ rawText = await this._readFile(source.file, 'utf8');
61
+ } catch (err) {
62
+ throw makeError(`Failed to read subject token file ${source.file}: ${err.message}`, 'ESubjectTokenRead', null, { cause: err });
63
+ }
64
+ return extractFromFormat(rawText, format);
65
+ }
66
+
67
+ let headers = { 'User-Agent': USER_AGENT };
68
+ if (source.headers && typeof source.headers === 'object') {
69
+ for (let key of Object.keys(source.headers)) {
70
+ headers[key] = source.headers[key];
71
+ }
72
+ }
73
+
74
+ let res;
75
+ try {
76
+ res = await this._fetch(source.url, {
77
+ method: 'GET',
78
+ headers,
79
+ dispatcher: httpAgent.retry
80
+ });
81
+ } catch (err) {
82
+ throw makeError(`Failed to fetch subject token from ${source.url}: ${err.message}`, 'ESubjectTokenRead', null, { cause: err });
83
+ }
84
+
85
+ let body = await res.text();
86
+
87
+ this._log('readSubjectToken', 'GET', source.url, res.ok, res.status);
88
+
89
+ if (!res.ok) {
90
+ throw makeError(`Subject token endpoint ${source.url} returned HTTP ${res.status}`, 'ESubjectTokenRead', res.status, {
91
+ responseText: body.slice(0, 1024)
92
+ });
93
+ }
94
+
95
+ return extractFromFormat(body, format);
96
+ }
97
+
98
+ // Exchange the subject token for a Google federated access token. The
99
+ // federated identity (the workload principal) carries the cloud-platform
100
+ // scope and, given roles/iam.serviceAccountTokenCreator on the target
101
+ // service account, may call signJwt directly. Returns the access token and
102
+ // its absolute expiry so it can be cached and reused across signJwt calls.
103
+ async _exchangeAtSts(subjectToken) {
104
+ let body = new URLSearchParams({
105
+ grant_type: STS_GRANT_TYPE,
106
+ audience: this.config.audience,
107
+ scope: CLOUD_PLATFORM_SCOPE,
108
+ requested_token_type: REQUESTED_TOKEN_TYPE,
109
+ subject_token_type: this.config.subject_token_type,
110
+ subject_token: subjectToken
111
+ });
112
+
113
+ let res;
114
+ let responseJson;
115
+ try {
116
+ res = await this._fetch(this.config.token_url, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/x-www-form-urlencoded',
120
+ Accept: 'application/json',
121
+ 'User-Agent': USER_AGENT
122
+ },
123
+ body: body.toString(),
124
+ dispatcher: httpAgent.retry
125
+ });
126
+ responseJson = await res.json().catch(() => null);
127
+ } catch (err) {
128
+ throw makeError(`STS token exchange request failed: ${err.message}`, 'ESTSExchange', null, { cause: err });
129
+ }
130
+
131
+ this._log('exchangeAtSts', 'POST', this.config.token_url, res.ok, res.status);
132
+
133
+ if (!res.ok) {
134
+ throw makeError(`STS token exchange at ${this.config.token_url} returned HTTP ${res.status}`, 'ESTSExchange', res.status, {
135
+ response: responseJson || null
136
+ });
137
+ }
138
+
139
+ let token = responseJson && responseJson.access_token;
140
+ if (typeof token !== 'string' || !token) {
141
+ throw makeError('STS response did not include an access_token', 'ESTSExchange', res.status, { response: responseJson || null });
142
+ }
143
+
144
+ let expiresInSec = responseJson && Number(responseJson.expires_in);
145
+ let ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1000 : DEFAULT_FEDERATED_TOKEN_TTL_MS;
146
+
147
+ return { accessToken: token, expiresAtMs: this._now() + ttlMs };
148
+ }
149
+
150
+ async _ensureFederatedToken() {
151
+ if (this._cachedToken && this._now() + TOKEN_EXPIRY_SKEW_MS < this._cachedTokenExpiresAt) {
152
+ return this._cachedToken;
153
+ }
154
+
155
+ // Deduplicate concurrent refreshes: when many accounts hit a cold cache
156
+ // at once, share the single in-flight exchange rather than stampeding STS
157
+ // with redundant token exchanges.
158
+ if (!this._pendingRefresh) {
159
+ this._pendingRefresh = (async () => {
160
+ try {
161
+ let subjectToken = await this._readSubjectToken();
162
+ let { accessToken, expiresAtMs } = await this._exchangeAtSts(subjectToken);
163
+ this._cachedToken = accessToken;
164
+ this._cachedTokenExpiresAt = expiresAtMs;
165
+ return accessToken;
166
+ } finally {
167
+ this._pendingRefresh = null;
168
+ }
169
+ })();
170
+ }
171
+
172
+ return this._pendingRefresh;
173
+ }
174
+
175
+ async _callSignJwt(accessToken, jwtPayload) {
176
+ let url = this._iamCredentialsBaseUrl + SIGN_JWT_URL_TEMPLATE.replace('{email}', encodeURIComponent(this.serviceAccountEmail));
177
+ let res;
178
+ let responseJson;
179
+ try {
180
+ res = await this._fetch(url, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'Content-Type': 'application/json',
184
+ Accept: 'application/json',
185
+ Authorization: `Bearer ${accessToken}`,
186
+ 'User-Agent': USER_AGENT
187
+ },
188
+ body: JSON.stringify({ payload: JSON.stringify(jwtPayload) }),
189
+ dispatcher: httpAgent.retry
190
+ });
191
+ responseJson = await res.json().catch(() => null);
192
+ } catch (err) {
193
+ throw makeError(`signJwt request failed: ${err.message}`, 'ESignJwt', null, { cause: err });
194
+ }
195
+
196
+ this._log('callSignJwt', 'POST', url, res.ok, res.status);
197
+
198
+ if (!res.ok) {
199
+ let retryAfter = null;
200
+ if (res.status === 429) {
201
+ let header = res.headers.get('retry-after');
202
+ if (header) {
203
+ let parsed = parseInt(header, 10);
204
+ retryAfter = isNaN(parsed) ? null : parsed;
205
+ }
206
+ }
207
+ throw makeError(`signJwt returned HTTP ${res.status}`, 'ESignJwt', res.status, {
208
+ response: responseJson || null,
209
+ retryAfter
210
+ });
211
+ }
212
+
213
+ let signedJwt = responseJson && responseJson.signedJwt;
214
+ if (typeof signedJwt !== 'string' || !signedJwt) {
215
+ throw makeError('signJwt response did not include signedJwt', 'ESignJwt', res.status, { response: responseJson || null });
216
+ }
217
+ return signedJwt;
218
+ }
219
+
220
+ /**
221
+ * Sign a JWT payload using the configured external account identity.
222
+ * Returns the compact JWT string suitable for the `assertion` parameter
223
+ * in a jwt-bearer grant exchange.
224
+ */
225
+ async sign(jwtPayload) {
226
+ let accessToken = await this._ensureFederatedToken();
227
+ return this._callSignJwt(accessToken, jwtPayload);
228
+ }
229
+
230
+ _log(fn, method, url, ok, status) {
231
+ if (!this.logger) {
232
+ return;
233
+ }
234
+ this.logger.info({
235
+ msg: 'External account signer request',
236
+ action: 'externalAccountFetch',
237
+ fn,
238
+ method,
239
+ url,
240
+ success: !!ok,
241
+ status,
242
+ credentialSource: this.describeCredentialSource().type,
243
+ targetServiceAccountEmail: this.serviceAccountEmail
244
+ });
245
+ }
246
+
247
+ // Test-only: clear the cached federated token.
248
+ _clearCache() {
249
+ this._cachedToken = null;
250
+ this._cachedTokenExpiresAt = 0;
251
+ this._pendingRefresh = null;
252
+ }
253
+ }
254
+
255
+ module.exports.ExternalAccountSigner = ExternalAccountSigner;
256
+ module.exports.__test__ = { validateConfig, extractFromFormat, makeError };