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.DownscopedClient = exports.EXPIRATION_TIME_OFFSET = exports.MAX_ACCESS_BOUNDARY_RULES_COUNT = void 0;
|
17 | const stream = require("stream");
|
18 | const authclient_1 = require("./authclient");
|
19 | const sts = require("./stscredentials");
|
20 | /**
|
21 | * The required token exchange grant_type: rfc8693#section-2.1
|
22 | */
|
23 | const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
24 | /**
|
25 | * The requested token exchange requested_token_type: rfc8693#section-2.1
|
26 | */
|
27 | const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
28 | /**
|
29 | * The requested token exchange subject_token_type: rfc8693#section-2.1
|
30 | */
|
31 | const STS_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
32 | /** The STS access token exchange end point. */
|
33 | const STS_ACCESS_TOKEN_URL = 'https://sts.googleapis.com/v1/token';
|
34 | /**
|
35 | * The maximum number of access boundary rules a Credential Access Boundary
|
36 | * can contain.
|
37 | */
|
38 | exports.MAX_ACCESS_BOUNDARY_RULES_COUNT = 10;
|
39 | /**
|
40 | * Offset to take into account network delays and server clock skews.
|
41 | */
|
42 | exports.EXPIRATION_TIME_OFFSET = 5 * 60 * 1000;
|
43 | /**
|
44 | * Defines a set of Google credentials that are downscoped from an existing set
|
45 | * of Google OAuth2 credentials. This is useful to restrict the Identity and
|
46 | * Access Management (IAM) permissions that a short-lived credential can use.
|
47 | * The common pattern of usage is to have a token broker with elevated access
|
48 | * generate these downscoped credentials from higher access source credentials
|
49 | * and pass the downscoped short-lived access tokens to a token consumer via
|
50 | * some secure authenticated channel for limited access to Google Cloud Storage
|
51 | * resources.
|
52 | */
|
53 | class DownscopedClient extends authclient_1.AuthClient {
|
54 | /**
|
55 | * Instantiates a downscoped client object using the provided source
|
56 | * AuthClient and credential access boundary rules.
|
57 | * To downscope permissions of a source AuthClient, a Credential Access
|
58 | * Boundary that specifies which resources the new credential can access, as
|
59 | * well as an upper bound on the permissions that are available on each
|
60 | * resource, has to be defined. A downscoped client can then be instantiated
|
61 | * using the source AuthClient and the Credential Access Boundary.
|
62 | * @param authClient The source AuthClient to be downscoped based on the
|
63 | * provided Credential Access Boundary rules.
|
64 | * @param credentialAccessBoundary The Credential Access Boundary which
|
65 | * contains a list of access boundary rules. Each rule contains information
|
66 | * on the resource that the rule applies to, the upper bound of the
|
67 | * permissions that are available on that resource and an optional
|
68 | * condition to further restrict permissions.
|
69 | * @param additionalOptions Optional additional behavior customization
|
70 | * options. These currently customize expiration threshold time and
|
71 | * whether to retry on 401/403 API request errors.
|
72 | * @param quotaProjectId Optional quota project id for setting up in the
|
73 | * x-goog-user-project header.
|
74 | */
|
75 | constructor(authClient, credentialAccessBoundary, additionalOptions, quotaProjectId) {
|
76 | super();
|
77 | this.authClient = authClient;
|
78 | this.credentialAccessBoundary = credentialAccessBoundary;
|
79 | // Check 1-10 Access Boundary Rules are defined within Credential Access
|
80 | // Boundary.
|
81 | if (credentialAccessBoundary.accessBoundary.accessBoundaryRules.length === 0) {
|
82 | throw new Error('At least one access boundary rule needs to be defined.');
|
83 | }
|
84 | else if (credentialAccessBoundary.accessBoundary.accessBoundaryRules.length >
|
85 | exports.MAX_ACCESS_BOUNDARY_RULES_COUNT) {
|
86 | throw new Error('The provided access boundary has more than ' +
|
87 | `${exports.MAX_ACCESS_BOUNDARY_RULES_COUNT} access boundary rules.`);
|
88 | }
|
89 | // Check at least one permission should be defined in each Access Boundary
|
90 | // Rule.
|
91 | for (const rule of credentialAccessBoundary.accessBoundary
|
92 | .accessBoundaryRules) {
|
93 | if (rule.availablePermissions.length === 0) {
|
94 | throw new Error('At least one permission should be defined in access boundary rules.');
|
95 | }
|
96 | }
|
97 | this.stsCredential = new sts.StsCredentials(STS_ACCESS_TOKEN_URL);
|
98 | this.cachedDownscopedAccessToken = null;
|
99 | // As threshold could be zero,
|
100 | // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the
|
101 | // zero value.
|
102 | if (typeof (additionalOptions === null || additionalOptions === void 0 ? void 0 : additionalOptions.eagerRefreshThresholdMillis) !== 'number') {
|
103 | this.eagerRefreshThresholdMillis = exports.EXPIRATION_TIME_OFFSET;
|
104 | }
|
105 | else {
|
106 | this.eagerRefreshThresholdMillis = additionalOptions
|
107 | .eagerRefreshThresholdMillis;
|
108 | }
|
109 | this.forceRefreshOnFailure = !!(additionalOptions === null || additionalOptions === void 0 ? void 0 : additionalOptions.forceRefreshOnFailure);
|
110 | this.quotaProjectId = quotaProjectId;
|
111 | }
|
112 | /**
|
113 | * Provides a mechanism to inject Downscoped access tokens directly.
|
114 | * The expiry_date field is required to facilitate determination of the token
|
115 | * expiration which would make it easier for the token consumer to handle.
|
116 | * @param credentials The Credentials object to set on the current client.
|
117 | */
|
118 | setCredentials(credentials) {
|
119 | if (!credentials.expiry_date) {
|
120 | throw new Error('The access token expiry_date field is missing in the provided ' +
|
121 | 'credentials.');
|
122 | }
|
123 | super.setCredentials(credentials);
|
124 | this.cachedDownscopedAccessToken = credentials;
|
125 | }
|
126 | async getAccessToken() {
|
127 | // If the cached access token is unavailable or expired, force refresh.
|
128 | // The Downscoped access token will be returned in
|
129 | // DownscopedAccessTokenResponse format.
|
130 | if (!this.cachedDownscopedAccessToken ||
|
131 | this.isExpired(this.cachedDownscopedAccessToken)) {
|
132 | await this.refreshAccessTokenAsync();
|
133 | }
|
134 | // Return Downscoped access token in DownscopedAccessTokenResponse format.
|
135 | return {
|
136 | token: this.cachedDownscopedAccessToken.access_token,
|
137 | expirationTime: this.cachedDownscopedAccessToken.expiry_date,
|
138 | res: this.cachedDownscopedAccessToken.res,
|
139 | };
|
140 | }
|
141 | /**
|
142 | * The main authentication interface. It takes an optional url which when
|
143 | * present is the endpoint being accessed, and returns a Promise which
|
144 | * resolves with authorization header fields.
|
145 | *
|
146 | * The result has the form:
|
147 | * { Authorization: 'Bearer <access_token_value>' }
|
148 | */
|
149 | async getRequestHeaders() {
|
150 | const accessTokenResponse = await this.getAccessToken();
|
151 | const headers = {
|
152 | Authorization: `Bearer ${accessTokenResponse.token}`,
|
153 | };
|
154 | return this.addSharedMetadataHeaders(headers);
|
155 | }
|
156 | request(opts, callback) {
|
157 | if (callback) {
|
158 | this.requestAsync(opts).then(r => callback(null, r), e => {
|
159 | return callback(e, e.response);
|
160 | });
|
161 | }
|
162 | else {
|
163 | return this.requestAsync(opts);
|
164 | }
|
165 | }
|
166 | /**
|
167 | * Authenticates the provided HTTP request, processes it and resolves with the
|
168 | * returned response.
|
169 | * @param opts The HTTP request options.
|
170 | * @param retry Whether the current attempt is a retry after a failed attempt.
|
171 | * @return A promise that resolves with the successful response.
|
172 | */
|
173 | async requestAsync(opts, retry = false) {
|
174 | let response;
|
175 | try {
|
176 | const requestHeaders = await this.getRequestHeaders();
|
177 | opts.headers = opts.headers || {};
|
178 | if (requestHeaders && requestHeaders['x-goog-user-project']) {
|
179 | opts.headers['x-goog-user-project'] =
|
180 | requestHeaders['x-goog-user-project'];
|
181 | }
|
182 | if (requestHeaders && requestHeaders.Authorization) {
|
183 | opts.headers.Authorization = requestHeaders.Authorization;
|
184 | }
|
185 | response = await this.transporter.request(opts);
|
186 | }
|
187 | catch (e) {
|
188 | const res = e.response;
|
189 | if (res) {
|
190 | const statusCode = res.status;
|
191 | // Retry the request for metadata if the following criteria are true:
|
192 | // - We haven't already retried. It only makes sense to retry once.
|
193 | // - The response was a 401 or a 403
|
194 | // - The request didn't send a readableStream
|
195 | // - forceRefreshOnFailure is true
|
196 | const isReadableStream = res.config.data instanceof stream.Readable;
|
197 | const isAuthErr = statusCode === 401 || statusCode === 403;
|
198 | if (!retry &&
|
199 | isAuthErr &&
|
200 | !isReadableStream &&
|
201 | this.forceRefreshOnFailure) {
|
202 | await this.refreshAccessTokenAsync();
|
203 | return await this.requestAsync(opts, true);
|
204 | }
|
205 | }
|
206 | throw e;
|
207 | }
|
208 | return response;
|
209 | }
|
210 | /**
|
211 | * Forces token refresh, even if unexpired tokens are currently cached.
|
212 | * GCP access tokens are retrieved from authclient object/source credential.
|
213 | * Then GCP access tokens are exchanged for downscoped access tokens via the
|
214 | * token exchange endpoint.
|
215 | * @return A promise that resolves with the fresh downscoped access token.
|
216 | */
|
217 | async refreshAccessTokenAsync() {
|
218 | var _a;
|
219 | // Retrieve GCP access token from source credential.
|
220 | const subjectToken = (await this.authClient.getAccessToken()).token;
|
221 | // Construct the STS credentials options.
|
222 | const stsCredentialsOptions = {
|
223 | grantType: STS_GRANT_TYPE,
|
224 | requestedTokenType: STS_REQUEST_TOKEN_TYPE,
|
225 | subjectToken: subjectToken,
|
226 | subjectTokenType: STS_SUBJECT_TOKEN_TYPE,
|
227 | };
|
228 | // Exchange the source AuthClient access token for a Downscoped access
|
229 | // token.
|
230 | const stsResponse = await this.stsCredential.exchangeToken(stsCredentialsOptions, undefined, this.credentialAccessBoundary);
|
231 | /**
|
232 | * The STS endpoint will only return the expiration time for the downscoped
|
233 | * access token if the original access token represents a service account.
|
234 | * The downscoped token's expiration time will always match the source
|
235 | * credential expiration. When no expires_in is returned, we can copy the
|
236 | * source credential's expiration time.
|
237 | */
|
238 | const sourceCredExpireDate = ((_a = this.authClient.credentials) === null || _a === void 0 ? void 0 : _a.expiry_date) || null;
|
239 | const expiryDate = stsResponse.expires_in
|
240 | ? new Date().getTime() + stsResponse.expires_in * 1000
|
241 | : sourceCredExpireDate;
|
242 | // Save response in cached access token.
|
243 | this.cachedDownscopedAccessToken = {
|
244 | access_token: stsResponse.access_token,
|
245 | expiry_date: expiryDate,
|
246 | res: stsResponse.res,
|
247 | };
|
248 | // Save credentials.
|
249 | this.credentials = {};
|
250 | Object.assign(this.credentials, this.cachedDownscopedAccessToken);
|
251 | delete this.credentials.res;
|
252 | // Trigger tokens event to notify external listeners.
|
253 | this.emit('tokens', {
|
254 | refresh_token: null,
|
255 | expiry_date: this.cachedDownscopedAccessToken.expiry_date,
|
256 | access_token: this.cachedDownscopedAccessToken.access_token,
|
257 | token_type: 'Bearer',
|
258 | id_token: null,
|
259 | });
|
260 | // Return the cached access token.
|
261 | return this.cachedDownscopedAccessToken;
|
262 | }
|
263 | /**
|
264 | * Returns whether the provided credentials are expired or not.
|
265 | * If there is no expiry time, assumes the token is not expired or expiring.
|
266 | * @param downscopedAccessToken The credentials to check for expiration.
|
267 | * @return Whether the credentials are expired or not.
|
268 | */
|
269 | isExpired(downscopedAccessToken) {
|
270 | const now = new Date().getTime();
|
271 | return downscopedAccessToken.expiry_date
|
272 | ? now >=
|
273 | downscopedAccessToken.expiry_date - this.eagerRefreshThresholdMillis
|
274 | : false;
|
275 | }
|
276 | }
|
277 | exports.DownscopedClient = DownscopedClient;
|
278 | //# sourceMappingURL=downscopedclient.js.map |
\ | No newline at end of file |