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.
- package/.github/codeql/codeql-config.yml +16 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/deploy.yml +6 -0
- package/.github/workflows/test.yml +6 -0
- package/CHANGELOG.md +36 -0
- package/SECURITY.md +80 -0
- package/SECURITY.txt +27 -0
- package/data/google-crawlers.json +7 -1
- package/lib/account.js +24 -1
- package/lib/api-routes/account-routes.js +12 -2
- package/lib/email-client/base-client.js +26 -20
- package/lib/email-client/gmail-client.js +14 -12
- package/lib/imapproxy/imap-core/lib/imap-command.js +1 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +7 -0
- package/lib/imapproxy/imap-core/lib/imap-server.js +1 -1
- package/lib/imapproxy/imap-server.js +92 -29
- package/lib/oauth/external-account-config.js +132 -0
- package/lib/oauth/external-account-signer.js +256 -0
- package/lib/oauth/gmail.js +113 -14
- package/lib/oauth/verify-app.js +397 -0
- package/lib/oauth2-apps.js +51 -6
- package/lib/routes-ui.js +153 -1
- package/lib/schemas.js +80 -2
- package/lib/settings.js +1 -0
- package/lib/tools.js +15 -10
- package/package.json +28 -28
- package/sbom.json +1 -1
- package/server.js +3 -3
- package/static/js/ace/ace.js +1 -1
- package/static/js/ace/ext-searchbox.js +1 -1
- package/static/js/ace/mode-handlebars.js +1 -1
- package/static/js/ace/mode-html.js +1 -1
- package/static/js/ace/mode-javascript.js +1 -1
- package/static/js/ace/mode-markdown.js +1 -1
- package/static/js/ace/worker-html.js +1 -1
- package/static/js/ace/worker-javascript.js +1 -1
- package/static/js/ace/worker-json.js +1 -1
- package/static/licenses.html +145 -115
- package/translations/messages.pot +49 -49
- package/views/config/oauth/app.hbs +224 -0
- package/views/config/oauth/edit.hbs +69 -0
- package/views/config/oauth/new.hbs +69 -0
- package/views/partials/oauth_form.hbs +99 -32
- 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('
|
|
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.
|
|
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
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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: '
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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 };
|