UNPKG

20.2 kBJavaScriptView Raw
1"use strict";
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.
15Object.defineProperty(exports, "__esModule", { value: true });
16exports.BaseExternalAccountClient = exports.DEFAULT_UNIVERSE = exports.CLOUD_RESOURCE_MANAGER = exports.EXTERNAL_ACCOUNT_TYPE = exports.EXPIRATION_TIME_OFFSET = void 0;
17const stream = require("stream");
18const authclient_1 = require("./authclient");
19const sts = require("./stscredentials");
20const util_1 = require("../util");
21/**
22 * The required token exchange grant_type: rfc8693#section-2.1
23 */
24const 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 */
28const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
29/** The default OAuth scope to request when none is provided. */
30const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
31/** Default impersonated token lifespan in seconds.*/
32const DEFAULT_TOKEN_LIFESPAN = 3600;
33/**
34 * Offset to take into account network delays and server clock skews.
35 */
36exports.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 */
44exports.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 **/
50exports.CLOUD_RESOURCE_MANAGER = 'https://cloudresourcemanager.googleapis.com/v1/projects/';
51/** The workforce audience pattern. */
52const WORKFORCE_AUDIENCE_PATTERN = '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+';
53const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token';
54// eslint-disable-next-line @typescript-eslint/no-var-requires
55const pkg = require('../../../package.json');
56/**
57 * For backwards compatibility.
58 */
59var authclient_2 = require("./authclient");
60Object.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 */
70class 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}
440exports.BaseExternalAccountClient = BaseExternalAccountClient;