1 | ;
|
2 | // Copyright 2021 Google LLC
|
3 | //
|
4 | // Licensed under the Apache License, Version 2.0 (the "License");
|
5 | // you may not use this file except in compliance with the License.
|
6 | // You may obtain a copy of the License at
|
7 | //
|
8 | // http://www.apache.org/licenses/LICENSE-2.0
|
9 | //
|
10 | // Unless required by applicable law or agreed to in writing, software
|
11 | // distributed under the License is distributed on an "AS IS" BASIS,
|
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 | // See the License for the specific language governing permissions and
|
14 | // limitations under the License.
|
15 | Object.defineProperty(exports, "__esModule", { value: true });
|
16 | exports.BaseExternalAccountClient = exports.DEFAULT_UNIVERSE = exports.CLOUD_RESOURCE_MANAGER = exports.EXTERNAL_ACCOUNT_TYPE = exports.EXPIRATION_TIME_OFFSET = void 0;
|
17 | const stream = require("stream");
|
18 | const authclient_1 = require("./authclient");
|
19 | const sts = require("./stscredentials");
|
20 | const util_1 = require("../util");
|
21 | /**
|
22 | * The required token exchange grant_type: rfc8693#section-2.1
|
23 | */
|
24 | const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
25 | /**
|
26 | * The requested token exchange requested_token_type: rfc8693#section-2.1
|
27 | */
|
28 | const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
29 | /** The default OAuth scope to request when none is provided. */
|
30 | const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
|
31 | /** Default impersonated token lifespan in seconds.*/
|
32 | const DEFAULT_TOKEN_LIFESPAN = 3600;
|
33 | /**
|
34 | * Offset to take into account network delays and server clock skews.
|
35 | */
|
36 | exports.EXPIRATION_TIME_OFFSET = 5 * 60 * 1000;
|
37 | /**
|
38 | * The credentials JSON file type for external account clients.
|
39 | * There are 3 types of JSON configs:
|
40 | * 1. authorized_user => Google end user credential
|
41 | * 2. service_account => Google service account credential
|
42 | * 3. external_Account => non-GCP service (eg. AWS, Azure, K8s)
|
43 | */
|
44 | exports.EXTERNAL_ACCOUNT_TYPE = 'external_account';
|
45 | /**
|
46 | * Cloud resource manager URL used to retrieve project information.
|
47 | *
|
48 | * @deprecated use {@link BaseExternalAccountClient.cloudResourceManagerURL} instead
|
49 | **/
|
50 | exports.CLOUD_RESOURCE_MANAGER = 'https://cloudresourcemanager.googleapis.com/v1/projects/';
|
51 | /** The workforce audience pattern. */
|
52 | const WORKFORCE_AUDIENCE_PATTERN = '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+';
|
53 | const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token';
|
54 | // eslint-disable-next-line @typescript-eslint/no-var-requires
|
55 | const pkg = require('../../../package.json');
|
56 | /**
|
57 | * For backwards compatibility.
|
58 | */
|
59 | var authclient_2 = require("./authclient");
|
60 | Object.defineProperty(exports, "DEFAULT_UNIVERSE", { enumerable: true, get: function () { return authclient_2.DEFAULT_UNIVERSE; } });
|
61 | /**
|
62 | * Base external account client. This is used to instantiate AuthClients for
|
63 | * exchanging external account credentials for GCP access token and authorizing
|
64 | * requests to GCP APIs.
|
65 | * The base class implements common logic for exchanging various type of
|
66 | * external credentials for GCP access token. The logic of determining and
|
67 | * retrieving the external credential based on the environment and
|
68 | * credential_source will be left for the subclasses.
|
69 | */
|
70 | class BaseExternalAccountClient extends authclient_1.AuthClient {
|
71 | /**
|
72 | * Instantiate a BaseExternalAccountClient instance using the provided JSON
|
73 | * object loaded from an external account credentials file.
|
74 | * @param options The external account options object typically loaded
|
75 | * from the external account JSON credential file. The camelCased options
|
76 | * are aliases for the snake_cased options.
|
77 | * @param additionalOptions **DEPRECATED, all options are available in the
|
78 | * `options` parameter.** Optional additional behavior customization options.
|
79 | * These currently customize expiration threshold time and whether to retry
|
80 | * on 401/403 API request errors.
|
81 | */
|
82 | constructor(options, additionalOptions) {
|
83 | var _a;
|
84 | super({ ...options, ...additionalOptions });
|
85 | const opts = (0, util_1.originalOrCamelOptions)(options);
|
86 | const type = opts.get('type');
|
87 | if (type && type !== exports.EXTERNAL_ACCOUNT_TYPE) {
|
88 | throw new Error(`Expected "${exports.EXTERNAL_ACCOUNT_TYPE}" type but ` +
|
89 | `received "${options.type}"`);
|
90 | }
|
91 | const clientId = opts.get('client_id');
|
92 | const clientSecret = opts.get('client_secret');
|
93 | const tokenUrl = (_a = opts.get('token_url')) !== null && _a !== void 0 ? _a : DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain);
|
94 | const subjectTokenType = opts.get('subject_token_type');
|
95 | const workforcePoolUserProject = opts.get('workforce_pool_user_project');
|
96 | const serviceAccountImpersonationUrl = opts.get('service_account_impersonation_url');
|
97 | const serviceAccountImpersonation = opts.get('service_account_impersonation');
|
98 | const serviceAccountImpersonationLifetime = (0, util_1.originalOrCamelOptions)(serviceAccountImpersonation).get('token_lifetime_seconds');
|
99 | this.cloudResourceManagerURL = new URL(opts.get('cloud_resource_manager_url') ||
|
100 | `https://cloudresourcemanager.${this.universeDomain}/v1/projects/`);
|
101 | if (clientId) {
|
102 | this.clientAuth = {
|
103 | confidentialClientType: 'basic',
|
104 | clientId,
|
105 | clientSecret,
|
106 | };
|
107 | }
|
108 | this.stsCredential = new sts.StsCredentials(tokenUrl, this.clientAuth);
|
109 | this.scopes = opts.get('scopes') || [DEFAULT_OAUTH_SCOPE];
|
110 | this.cachedAccessToken = null;
|
111 | this.audience = opts.get('audience');
|
112 | this.subjectTokenType = subjectTokenType;
|
113 | this.workforcePoolUserProject = workforcePoolUserProject;
|
114 | const workforceAudiencePattern = new RegExp(WORKFORCE_AUDIENCE_PATTERN);
|
115 | if (this.workforcePoolUserProject &&
|
116 | !this.audience.match(workforceAudiencePattern)) {
|
117 | throw new Error('workforcePoolUserProject should not be set for non-workforce pool ' +
|
118 | 'credentials.');
|
119 | }
|
120 | this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl;
|
121 | this.serviceAccountImpersonationLifetime =
|
122 | serviceAccountImpersonationLifetime;
|
123 | if (this.serviceAccountImpersonationLifetime) {
|
124 | this.configLifetimeRequested = true;
|
125 | }
|
126 | else {
|
127 | this.configLifetimeRequested = false;
|
128 | this.serviceAccountImpersonationLifetime = DEFAULT_TOKEN_LIFESPAN;
|
129 | }
|
130 | this.projectNumber = this.getProjectNumber(this.audience);
|
131 | this.supplierContext = {
|
132 | audience: this.audience,
|
133 | subjectTokenType: this.subjectTokenType,
|
134 | transporter: this.transporter,
|
135 | };
|
136 | }
|
137 | /** The service account email to be impersonated, if available. */
|
138 | getServiceAccountEmail() {
|
139 | var _a;
|
140 | if (this.serviceAccountImpersonationUrl) {
|
141 | if (this.serviceAccountImpersonationUrl.length > 256) {
|
142 | /**
|
143 | * Prevents DOS attacks.
|
144 | * @see {@link https://github.com/googleapis/google-auth-library-nodejs/security/code-scanning/84}
|
145 | **/
|
146 | throw new RangeError(`URL is too long: ${this.serviceAccountImpersonationUrl}`);
|
147 | }
|
148 | // Parse email from URL. The formal looks as follows:
|
149 | // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
|
150 | const re = /serviceAccounts\/(?<email>[^:]+):generateAccessToken$/;
|
151 | const result = re.exec(this.serviceAccountImpersonationUrl);
|
152 | return ((_a = result === null || result === void 0 ? void 0 : result.groups) === null || _a === void 0 ? void 0 : _a.email) || null;
|
153 | }
|
154 | return null;
|
155 | }
|
156 | /**
|
157 | * Provides a mechanism to inject GCP access tokens directly.
|
158 | * When the provided credential expires, a new credential, using the
|
159 | * external account options, is retrieved.
|
160 | * @param credentials The Credentials object to set on the current client.
|
161 | */
|
162 | setCredentials(credentials) {
|
163 | super.setCredentials(credentials);
|
164 | this.cachedAccessToken = credentials;
|
165 | }
|
166 | /**
|
167 | * @return A promise that resolves with the current GCP access token
|
168 | * response. If the current credential is expired, a new one is retrieved.
|
169 | */
|
170 | async getAccessToken() {
|
171 | // If cached access token is unavailable or expired, force refresh.
|
172 | if (!this.cachedAccessToken || this.isExpired(this.cachedAccessToken)) {
|
173 | await this.refreshAccessTokenAsync();
|
174 | }
|
175 | // Return GCP access token in GetAccessTokenResponse format.
|
176 | return {
|
177 | token: this.cachedAccessToken.access_token,
|
178 | res: this.cachedAccessToken.res,
|
179 | };
|
180 | }
|
181 | /**
|
182 | * The main authentication interface. It takes an optional url which when
|
183 | * present is the endpoint being accessed, and returns a Promise which
|
184 | * resolves with authorization header fields.
|
185 | *
|
186 | * The result has the form:
|
187 | * { Authorization: 'Bearer <access_token_value>' }
|
188 | */
|
189 | async getRequestHeaders() {
|
190 | const accessTokenResponse = await this.getAccessToken();
|
191 | const headers = {
|
192 | Authorization: `Bearer ${accessTokenResponse.token}`,
|
193 | };
|
194 | return this.addSharedMetadataHeaders(headers);
|
195 | }
|
196 | request(opts, callback) {
|
197 | if (callback) {
|
198 | this.requestAsync(opts).then(r => callback(null, r), e => {
|
199 | return callback(e, e.response);
|
200 | });
|
201 | }
|
202 | else {
|
203 | return this.requestAsync(opts);
|
204 | }
|
205 | }
|
206 | /**
|
207 | * @return A promise that resolves with the project ID corresponding to the
|
208 | * current workload identity pool or current workforce pool if
|
209 | * determinable. For workforce pool credential, it returns the project ID
|
210 | * corresponding to the workforcePoolUserProject.
|
211 | * This is introduced to match the current pattern of using the Auth
|
212 | * library:
|
213 | * const projectId = await auth.getProjectId();
|
214 | * const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`;
|
215 | * const res = await client.request({ url });
|
216 | * The resource may not have permission
|
217 | * (resourcemanager.projects.get) to call this API or the required
|
218 | * scopes may not be selected:
|
219 | * https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
|
220 | */
|
221 | async getProjectId() {
|
222 | const projectNumber = this.projectNumber || this.workforcePoolUserProject;
|
223 | if (this.projectId) {
|
224 | // Return previously determined project ID.
|
225 | return this.projectId;
|
226 | }
|
227 | else if (projectNumber) {
|
228 | // Preferable not to use request() to avoid retrial policies.
|
229 | const headers = await this.getRequestHeaders();
|
230 | const response = await this.transporter.request({
|
231 | ...BaseExternalAccountClient.RETRY_CONFIG,
|
232 | headers,
|
233 | url: `${this.cloudResourceManagerURL.toString()}${projectNumber}`,
|
234 | responseType: 'json',
|
235 | });
|
236 | this.projectId = response.data.projectId;
|
237 | return this.projectId;
|
238 | }
|
239 | return null;
|
240 | }
|
241 | /**
|
242 | * Authenticates the provided HTTP request, processes it and resolves with the
|
243 | * returned response.
|
244 | * @param opts The HTTP request options.
|
245 | * @param reAuthRetried Whether the current attempt is a retry after a failed attempt due to an auth failure.
|
246 | * @return A promise that resolves with the successful response.
|
247 | */
|
248 | async requestAsync(opts, reAuthRetried = false) {
|
249 | let response;
|
250 | try {
|
251 | const requestHeaders = await this.getRequestHeaders();
|
252 | opts.headers = opts.headers || {};
|
253 | if (requestHeaders && requestHeaders['x-goog-user-project']) {
|
254 | opts.headers['x-goog-user-project'] =
|
255 | requestHeaders['x-goog-user-project'];
|
256 | }
|
257 | if (requestHeaders && requestHeaders.Authorization) {
|
258 | opts.headers.Authorization = requestHeaders.Authorization;
|
259 | }
|
260 | response = await this.transporter.request(opts);
|
261 | }
|
262 | catch (e) {
|
263 | const res = e.response;
|
264 | if (res) {
|
265 | const statusCode = res.status;
|
266 | // Retry the request for metadata if the following criteria are true:
|
267 | // - We haven't already retried. It only makes sense to retry once.
|
268 | // - The response was a 401 or a 403
|
269 | // - The request didn't send a readableStream
|
270 | // - forceRefreshOnFailure is true
|
271 | const isReadableStream = res.config.data instanceof stream.Readable;
|
272 | const isAuthErr = statusCode === 401 || statusCode === 403;
|
273 | if (!reAuthRetried &&
|
274 | isAuthErr &&
|
275 | !isReadableStream &&
|
276 | this.forceRefreshOnFailure) {
|
277 | await this.refreshAccessTokenAsync();
|
278 | return await this.requestAsync(opts, true);
|
279 | }
|
280 | }
|
281 | throw e;
|
282 | }
|
283 | return response;
|
284 | }
|
285 | /**
|
286 | * Forces token refresh, even if unexpired tokens are currently cached.
|
287 | * External credentials are exchanged for GCP access tokens via the token
|
288 | * exchange endpoint and other settings provided in the client options
|
289 | * object.
|
290 | * If the service_account_impersonation_url is provided, an additional
|
291 | * step to exchange the external account GCP access token for a service
|
292 | * account impersonated token is performed.
|
293 | * @return A promise that resolves with the fresh GCP access tokens.
|
294 | */
|
295 | async refreshAccessTokenAsync() {
|
296 | // Retrieve the external credential.
|
297 | const subjectToken = await this.retrieveSubjectToken();
|
298 | // Construct the STS credentials options.
|
299 | const stsCredentialsOptions = {
|
300 | grantType: STS_GRANT_TYPE,
|
301 | audience: this.audience,
|
302 | requestedTokenType: STS_REQUEST_TOKEN_TYPE,
|
303 | subjectToken,
|
304 | subjectTokenType: this.subjectTokenType,
|
305 | // generateAccessToken requires the provided access token to have
|
306 | // scopes:
|
307 | // https://www.googleapis.com/auth/iam or
|
308 | // https://www.googleapis.com/auth/cloud-platform
|
309 | // The new service account access token scopes will match the user
|
310 | // provided ones.
|
311 | scope: this.serviceAccountImpersonationUrl
|
312 | ? [DEFAULT_OAUTH_SCOPE]
|
313 | : this.getScopesArray(),
|
314 | };
|
315 | // Exchange the external credentials for a GCP access token.
|
316 | // Client auth is prioritized over passing the workforcePoolUserProject
|
317 | // parameter for STS token exchange.
|
318 | const additionalOptions = !this.clientAuth && this.workforcePoolUserProject
|
319 | ? { userProject: this.workforcePoolUserProject }
|
320 | : undefined;
|
321 | const additionalHeaders = {
|
322 | 'x-goog-api-client': this.getMetricsHeaderValue(),
|
323 | };
|
324 | const stsResponse = await this.stsCredential.exchangeToken(stsCredentialsOptions, additionalHeaders, additionalOptions);
|
325 | if (this.serviceAccountImpersonationUrl) {
|
326 | this.cachedAccessToken = await this.getImpersonatedAccessToken(stsResponse.access_token);
|
327 | }
|
328 | else if (stsResponse.expires_in) {
|
329 | // Save response in cached access token.
|
330 | this.cachedAccessToken = {
|
331 | access_token: stsResponse.access_token,
|
332 | expiry_date: new Date().getTime() + stsResponse.expires_in * 1000,
|
333 | res: stsResponse.res,
|
334 | };
|
335 | }
|
336 | else {
|
337 | // Save response in cached access token.
|
338 | this.cachedAccessToken = {
|
339 | access_token: stsResponse.access_token,
|
340 | res: stsResponse.res,
|
341 | };
|
342 | }
|
343 | // Save credentials.
|
344 | this.credentials = {};
|
345 | Object.assign(this.credentials, this.cachedAccessToken);
|
346 | delete this.credentials.res;
|
347 | // Trigger tokens event to notify external listeners.
|
348 | this.emit('tokens', {
|
349 | refresh_token: null,
|
350 | expiry_date: this.cachedAccessToken.expiry_date,
|
351 | access_token: this.cachedAccessToken.access_token,
|
352 | token_type: 'Bearer',
|
353 | id_token: null,
|
354 | });
|
355 | // Return the cached access token.
|
356 | return this.cachedAccessToken;
|
357 | }
|
358 | /**
|
359 | * Returns the workload identity pool project number if it is determinable
|
360 | * from the audience resource name.
|
361 | * @param audience The STS audience used to determine the project number.
|
362 | * @return The project number associated with the workload identity pool, if
|
363 | * this can be determined from the STS audience field. Otherwise, null is
|
364 | * returned.
|
365 | */
|
366 | getProjectNumber(audience) {
|
367 | // STS audience pattern:
|
368 | // //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
|
369 | const match = audience.match(/\/projects\/([^/]+)/);
|
370 | if (!match) {
|
371 | return null;
|
372 | }
|
373 | return match[1];
|
374 | }
|
375 | /**
|
376 | * Exchanges an external account GCP access token for a service
|
377 | * account impersonated access token using iamcredentials
|
378 | * GenerateAccessToken API.
|
379 | * @param token The access token to exchange for a service account access
|
380 | * token.
|
381 | * @return A promise that resolves with the service account impersonated
|
382 | * credentials response.
|
383 | */
|
384 | async getImpersonatedAccessToken(token) {
|
385 | const opts = {
|
386 | ...BaseExternalAccountClient.RETRY_CONFIG,
|
387 | url: this.serviceAccountImpersonationUrl,
|
388 | method: 'POST',
|
389 | headers: {
|
390 | 'Content-Type': 'application/json',
|
391 | Authorization: `Bearer ${token}`,
|
392 | },
|
393 | data: {
|
394 | scope: this.getScopesArray(),
|
395 | lifetime: this.serviceAccountImpersonationLifetime + 's',
|
396 | },
|
397 | responseType: 'json',
|
398 | };
|
399 | const response = await this.transporter.request(opts);
|
400 | const successResponse = response.data;
|
401 | return {
|
402 | access_token: successResponse.accessToken,
|
403 | // Convert from ISO format to timestamp.
|
404 | expiry_date: new Date(successResponse.expireTime).getTime(),
|
405 | res: response,
|
406 | };
|
407 | }
|
408 | /**
|
409 | * Returns whether the provided credentials are expired or not.
|
410 | * If there is no expiry time, assumes the token is not expired or expiring.
|
411 | * @param accessToken The credentials to check for expiration.
|
412 | * @return Whether the credentials are expired or not.
|
413 | */
|
414 | isExpired(accessToken) {
|
415 | const now = new Date().getTime();
|
416 | return accessToken.expiry_date
|
417 | ? now >= accessToken.expiry_date - this.eagerRefreshThresholdMillis
|
418 | : false;
|
419 | }
|
420 | /**
|
421 | * @return The list of scopes for the requested GCP access token.
|
422 | */
|
423 | getScopesArray() {
|
424 | // Since scopes can be provided as string or array, the type should
|
425 | // be normalized.
|
426 | if (typeof this.scopes === 'string') {
|
427 | return [this.scopes];
|
428 | }
|
429 | return this.scopes || [DEFAULT_OAUTH_SCOPE];
|
430 | }
|
431 | getMetricsHeaderValue() {
|
432 | const nodeVersion = process.version.replace(/^v/, '');
|
433 | const saImpersonation = this.serviceAccountImpersonationUrl !== undefined;
|
434 | const credentialSourceType = this.credentialSourceType
|
435 | ? this.credentialSourceType
|
436 | : 'unknown';
|
437 | return `gl-node/${nodeVersion} auth/${pkg.version} google-byoid-sdk source/${credentialSourceType} sa-impersonation/${saImpersonation} config-lifetime/${this.configLifetimeRequested}`;
|
438 | }
|
439 | }
|
440 | exports.BaseExternalAccountClient = BaseExternalAccountClient;
|