emailengine-app 2.67.3 → 2.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +6 -0
  4. package/.github/workflows/test.yml +6 -0
  5. package/CHANGELOG.md +36 -0
  6. package/SECURITY.md +80 -0
  7. package/SECURITY.txt +27 -0
  8. package/data/google-crawlers.json +7 -1
  9. package/lib/account.js +24 -1
  10. package/lib/api-routes/account-routes.js +12 -2
  11. package/lib/email-client/base-client.js +26 -20
  12. package/lib/email-client/gmail-client.js +14 -12
  13. package/lib/imapproxy/imap-core/lib/imap-command.js +1 -1
  14. package/lib/imapproxy/imap-core/lib/imap-connection.js +7 -0
  15. package/lib/imapproxy/imap-core/lib/imap-server.js +1 -1
  16. package/lib/imapproxy/imap-server.js +92 -29
  17. package/lib/oauth/external-account-config.js +132 -0
  18. package/lib/oauth/external-account-signer.js +256 -0
  19. package/lib/oauth/gmail.js +113 -14
  20. package/lib/oauth/verify-app.js +397 -0
  21. package/lib/oauth2-apps.js +51 -6
  22. package/lib/routes-ui.js +153 -1
  23. package/lib/schemas.js +80 -2
  24. package/lib/settings.js +1 -0
  25. package/lib/tools.js +15 -10
  26. package/package.json +28 -28
  27. package/sbom.json +1 -1
  28. package/server.js +3 -3
  29. package/static/js/ace/ace.js +1 -1
  30. package/static/js/ace/ext-searchbox.js +1 -1
  31. package/static/js/ace/mode-handlebars.js +1 -1
  32. package/static/js/ace/mode-html.js +1 -1
  33. package/static/js/ace/mode-javascript.js +1 -1
  34. package/static/js/ace/mode-markdown.js +1 -1
  35. package/static/js/ace/worker-html.js +1 -1
  36. package/static/js/ace/worker-javascript.js +1 -1
  37. package/static/js/ace/worker-json.js +1 -1
  38. package/static/licenses.html +145 -115
  39. package/translations/messages.pot +49 -49
  40. package/views/config/oauth/app.hbs +224 -0
  41. package/views/config/oauth/edit.hbs +69 -0
  42. package/views/config/oauth/new.hbs +69 -0
  43. package/views/partials/oauth_form.hbs +99 -32
  44. package/workers/api.js +91 -2
@@ -71,6 +71,30 @@ async function call(message, transferList) {
71
71
  });
72
72
  }
73
73
 
74
+ // Resolve pending call() promises when the main thread answers. Without this any
75
+ // RPC issued from this worker (via the `call` passed into Account) would never settle
76
+ // and would reject on timeout. Mirrors the handlers in workers/imap.js and smtp.js.
77
+ parentPort.on('message', message => {
78
+ if (message && message.cmd === 'resp' && message.mid && callQueue.has(message.mid)) {
79
+ let { resolve, reject, timer } = callQueue.get(message.mid);
80
+ clearTimeout(timer);
81
+ callQueue.delete(message.mid);
82
+
83
+ if (message.error) {
84
+ let err = new Error(message.error);
85
+ if (message.code) {
86
+ err.code = message.code;
87
+ }
88
+ if (message.statusCode) {
89
+ err.statusCode = message.statusCode;
90
+ }
91
+ return reject(err);
92
+ }
93
+
94
+ return resolve(message.response);
95
+ }
96
+ });
97
+
74
98
  async function metrics(logger, key, method, ...args) {
75
99
  try {
76
100
  parentPort.postMessage({
@@ -86,7 +110,7 @@ async function metrics(logger, key, method, ...args) {
86
110
 
87
111
  const { ImapFlow } = require('imapflow');
88
112
  const { IMAPServer, imapHandler } = require('./imap-core/index.js');
89
- const { PassThrough } = require('./imap-core/lib/length-limiter.js');
113
+ const { PassThrough } = require('stream');
90
114
 
91
115
  const packageInfo = require('../../package.json');
92
116
  const util = require('util');
@@ -113,7 +137,7 @@ class PassThroughLogger extends PassThrough {
113
137
  data: chunk.toString('base64'),
114
138
  compress: !!this.imapClient._deflate,
115
139
  secure: !!this.imapClient.secureConnection,
116
- cid: this.id
140
+ cid: this.cid
117
141
  });
118
142
  }
119
143
 
@@ -361,7 +385,7 @@ const createServer = function (options = {}) {
361
385
  message = util.format(message, ...args);
362
386
  }
363
387
  data.msg = message;
364
- if (typeof logger[level] === 'function') {
388
+ if (typeof serverLogger[level] === 'function') {
365
389
  serverLogger[level](data);
366
390
  } else {
367
391
  serverLogger.debug(data);
@@ -408,39 +432,78 @@ const createServer = function (options = {}) {
408
432
  logger: proxyLogger.child({ src: 'C' })
409
433
  });
410
434
 
435
+ // Idempotent teardown for both legs of the proxy. ImapFlow.close() is
436
+ // safe after unbind(): it sends no LOGOUT, clears timers (including
437
+ // autoidle), removes the deflate/writeSocket error forwarders and
438
+ // destroys the upstream socket. Without it the upstream connection and
439
+ // its idle timer would leak (most visibly with COMPRESS enabled).
440
+ let proxyClosed = false;
441
+ const closeProxy = () => {
442
+ if (proxyClosed) {
443
+ return;
444
+ }
445
+ proxyClosed = true;
446
+
447
+ try {
448
+ downstream.imapClient.close();
449
+ } catch (err) {
450
+ proxyLogger.error({ msg: 'Failed to close upstream connection', err });
451
+ }
452
+
453
+ try {
454
+ if (upstream.socket && !upstream.socket.destroyed) {
455
+ upstream.socket.end();
456
+ }
457
+ } catch (err) {
458
+ // ignore
459
+ }
460
+ };
461
+
462
+ // Every terminal event from either leg funnels into the idempotent
463
+ // closeProxy(). The helper keeps that wiring declarative; pass a message
464
+ // to log (level defaults to 'error', use 'info' for graceful closes).
465
+ const teardownOn = (emitter, event, msg, level = 'error') => {
466
+ emitter.on(event, err => {
467
+ if (msg) {
468
+ let entry = { msg };
469
+ if (err) {
470
+ entry.err = err;
471
+ }
472
+ proxyLogger[level](entry);
473
+ }
474
+ closeProxy();
475
+ });
476
+ };
477
+
411
478
  downstream.readSocket.pipe(upstreamLogger).pipe(upstream.socket);
412
479
  upstream.socket.pipe(downstreamLogger).pipe(downstream.writeSocket);
413
480
 
414
- upstreamLogger.on('error', err => {
415
- proxyLogger.error({ msg: 'Client error', err });
416
- upstream.socket.end();
417
- });
418
-
419
- downstreamLogger.on('error', err => {
420
- proxyLogger.error({ msg: 'Server error', err });
421
- downstream.writeSocket.end();
422
- downstream.readSocket.end();
423
- });
481
+ teardownOn(upstreamLogger, 'error', 'Proxy stream error (to client)');
482
+ teardownOn(downstreamLogger, 'error', 'Proxy stream error (to upstream)');
483
+ teardownOn(upstream.socket, 'error', 'Client socket error');
484
+ teardownOn(upstream.socket, 'end', 'Client connection closed', 'info');
485
+ teardownOn(upstream.socket, 'close');
486
+ teardownOn(downstream.readSocket, 'end', 'Upstream connection closed', 'info');
424
487
 
425
488
  downstream.readSocket.on('error', err => {
426
- proxyLogger.error({ msg: 'Client error', err });
427
- upstreamLogger.end('* BYE Upstream connection error\r\n');
428
- });
429
-
430
- upstream.socket.on('error', err => {
431
- proxyLogger.error({ msg: 'Upstream error', err });
432
- downstreamLogger.end();
489
+ proxyLogger.error({ msg: 'Upstream read error', err });
490
+ try {
491
+ // best-effort notice to the client before tearing down
492
+ upstream.socket.write('* BYE Upstream connection error\r\n');
493
+ } catch (e) {
494
+ // ignore
495
+ }
496
+ closeProxy();
433
497
  });
434
498
 
435
- downstream.readSocket.on('end', () => {
436
- proxyLogger.info({ msg: 'Client connection closed' });
437
- upstreamLogger.end();
438
- });
439
-
440
- upstream.socket.on('end', () => {
441
- proxyLogger.info({ msg: 'Server connection closed' });
442
- downstreamLogger.end();
443
- });
499
+ // With COMPRESS enabled, readSocket/writeSocket are the inflate/deflate
500
+ // streams, not the raw upstream socket, and unbind() removed ImapFlow's
501
+ // own listeners from that socket. An upstream reset would then emit an
502
+ // 'error' with no listener and crash the worker - guard it explicitly.
503
+ if (downstream.imapClient.socket && downstream.imapClient.socket !== downstream.readSocket) {
504
+ teardownOn(downstream.imapClient.socket, 'error', 'Upstream socket error');
505
+ teardownOn(downstream.imapClient.socket, 'close');
506
+ }
444
507
 
445
508
  proxyLogger.info({ msg: 'Proxy mode enabled' });
446
509
  };
@@ -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 };