Skip to content

Commit

Permalink
feat: defines ExternalAccountClient abstract class for external_accou…
Browse files Browse the repository at this point in the history
…nt credentials (#1030)
  • Loading branch information
bojeil-google authored Aug 14, 2020
1 parent 7f8b124 commit 1174e4a
Show file tree
Hide file tree
Showing 7 changed files with 1,471 additions and 4 deletions.
1 change: 0 additions & 1 deletion .kokoro/populate-secrets.sh

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .kokoro/release/docs-devsite.sh

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

[1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions

### [6.0.7](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.6...v6.0.7) (2020-08-11)


### Bug Fixes

* migrate token info API to not pass token in query string ([#991](https://www.github.com/googleapis/google-auth-library-nodejs/issues/991)) ([a7e5701](https://www.github.com/googleapis/google-auth-library-nodejs/commit/a7e5701a8394d79fe93d28794467747a23cf9ff4))

### [6.0.6](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.5...v6.0.6) (2020-07-30)


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "google-auth-library",
"version": "6.0.6",
"version": "6.0.7",
"author": "Google Inc.",
"description": "Google APIs Authentication Client Library for Node.js",
"engines": {
Expand Down
334 changes: 334 additions & 0 deletions src/auth/externalclient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {
GaxiosError,
GaxiosOptions,
GaxiosPromise,
GaxiosResponse,
} from 'gaxios';
import * as stream from 'stream';

import {Credentials} from './credentials';
import {AuthClient} from './authclient';
import {BodyResponseCallback} from '../transporters';
import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client';
import * as sts from './stscredentials';
import {ClientAuthentication} from './oauth2common';

/**
* The required token exchange grant_type: rfc8693#section-2.1
*/
const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
/**
* The requested token exchange requested_token_type: rfc8693#section-2.1
*/
const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
/** The default OAuth scope to request when none is provided. */
const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
/**
* Offset to take into account network delays and server clock skews.
*/
export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000;
/** The credentials JSON file type for external account clients. */
const EXTERNAL_ACCOUNT_TYPE = 'external_account';

/**
* Base external account credentials json interface.
*/
export interface ExternalAccountClientOptions {
type: string;
audience: string;
subject_token_type: string;
service_account_impersonation_url?: string;
token_url: string;
token_info_url?: string;
client_id?: string;
client_secret?: string;
quota_project_id?: string;
}

/**
* Internal interface for tracking the access token expiration time.
*/
interface CredentialsWithResponse extends Credentials {
res?: GaxiosResponse | null;
}

/**
* Base external account client. This is used to instantiate AuthClients for
* exchanging external account credentials for GCP access token and authorizing
* requests to GCP APIs.
* The base class implements common logic for exchanging various type of
* external credentials for GCP access token. The logic of determining and
* retrieving the external credential based on the environment and
* credential_source will be left for the subclasses.
*/
export abstract class ExternalAccountClient extends AuthClient {
/**
* OAuth scopes for the GCP access token to use. When not provided,
* the default https://www.googleapis.com/auth/cloud-platform is
* used.
*/
public scopes?: string | string[];
private cachedAccessToken: CredentialsWithResponse | null;
private eagerRefreshThresholdMillis: number;
private forceRefreshOnFailure: boolean;
private readonly audience: string;
private readonly subjectTokenType: string;
private readonly stsCredential: sts.StsCredentials;

/**
* Instantiate an ExternalAccountClient instance using the provided JSON
* object loaded from an external account credentials file.
* @param options The external account options object typically loaded
* from the external account JSON credential file.
* @param additionalOptions Optional additional behavior customization
* options. These currently customize expiration threshold time and
* whether to retry on 401/403 API request errors.
*/
constructor(
options: ExternalAccountClientOptions,
additionalOptions?: RefreshOptions
) {
super();
if (options.type !== EXTERNAL_ACCOUNT_TYPE) {
throw new Error(
`Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` +
`received "${options.type}"`
);
}
const clientAuth = options.client_id
? ({
confidentialClientType: 'basic',
clientId: options.client_id,
clientSecret: options.client_secret,
} as ClientAuthentication)
: undefined;
this.stsCredential = new sts.StsCredentials(options.token_url, clientAuth);
// Default OAuth scope. This could be overridden via public property.
this.scopes = [DEFAULT_OAUTH_SCOPE];
this.cachedAccessToken = null;
this.audience = options.audience;
this.subjectTokenType = options.subject_token_type;
this.quotaProjectId = options.quota_project_id;
// As threshold could be zero,
// eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the
// zero value.
if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') {
this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET;
} else {
this.eagerRefreshThresholdMillis = additionalOptions!
.eagerRefreshThresholdMillis as number;
}
this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure;
}

/**
* Provides a mechanism to inject GCP access tokens directly.
* When the provided credential expires, a new credential, using the
* external account options, is retrieved.
* @param credentials The Credentials object to set on the current client.
*/
setCredentials(credentials: Credentials) {
super.setCredentials(credentials);
this.cachedAccessToken = credentials;
}

/**
* Triggered when a external subject token is needed to be exchanged for a GCP
* access token via GCP STS endpoint.
* This abstract method needs to be implemented by subclasses depending on
* the type of external credential used.
* @return A promise that resolves with the external subject token.
*/
abstract async retrieveSubjectToken(): Promise<string>;

/**
* @return A promise that resolves with the current GCP access token
* response. If the current credential is expired, a new one is retrieved.
*/
async getAccessToken(): Promise<GetAccessTokenResponse> {
// If cached access token is unavailable or expired, force refresh.
if (!this.cachedAccessToken || this.isExpired(this.cachedAccessToken)) {
await this.refreshAccessTokenAsync();
}
// Return GCP access token in GetAccessTokenResponse format.
return {
token: this.cachedAccessToken!.access_token,
res: this.cachedAccessToken!.res,
};
}

/**
* The main authentication interface. It takes an optional url which when
* present is the endpoint> being accessed, and returns a Promise which
* resolves with authorization header fields.
*
* The result has the form:
* { Authorization: 'Bearer <access_token_value>' }
*/
async getRequestHeaders(): Promise<Headers> {
const accessTokenResponse = await this.getAccessToken();
const headers: Headers = {
Authorization: `Bearer ${accessTokenResponse.token}`,
};
return this.addSharedMetadataHeaders(headers);
}

/**
* Provides a request implementation with OAuth 2.0 flow. In cases of
* HTTP 401 and 403 responses, it automatically asks for a new access token
* and replays the unsuccessful request.
* @param opts Request options.
* @param callback callback.
* @return A promise that resolves with the HTTP response when no callback is
* provided.
*/
request<T>(opts: GaxiosOptions): GaxiosPromise<T>;
request<T>(opts: GaxiosOptions, callback: BodyResponseCallback<T>): void;
request<T>(
opts: GaxiosOptions,
callback?: BodyResponseCallback<T>
): GaxiosPromise<T> | void {
if (callback) {
this.requestAsync<T>(opts).then(
r => callback(null, r),
e => {
return callback(e, e.response);
}
);
} else {
return this.requestAsync<T>(opts);
}
}

/**
* Authenticates the provided HTTP request, processes it and resolves with the
* returned response.
* @param opts The HTTP request options.
* @param retry Whether the current attempt is a retry after a failed attempt.
* @return A promise that resolves with the successful response.
*/
protected async requestAsync<T>(
opts: GaxiosOptions,
retry = false
): Promise<GaxiosResponse<T>> {
let r2: GaxiosResponse;
try {
const r = await this.getRequestHeaders();
opts.headers = opts.headers || {};
if (r && r['x-goog-user-project']) {
opts.headers['x-goog-user-project'] = r['x-goog-user-project'];
}
if (r && r.Authorization) {
opts.headers.Authorization = r.Authorization;
}
r2 = await this.transporter.request<T>(opts);
} catch (e) {
const res = (e as GaxiosError).response;
if (res) {
const statusCode = res.status;
// Retry the request for metadata if the following criteria are true:
// - We haven't already retried. It only makes sense to retry once.
// - The response was a 401 or a 403
// - The request didn't send a readableStream
// - forceRefreshOnFailure is true
const isReadableStream = res.config.data instanceof stream.Readable;
const isAuthErr = statusCode === 401 || statusCode === 403;
if (
!retry &&
isAuthErr &&
!isReadableStream &&
this.forceRefreshOnFailure
) {
await this.refreshAccessTokenAsync();
return this.requestAsync<T>(opts, true);
}
}
throw e;
}
return r2;
}

/**
* Forces token refresh, even if unexpired tokens are currently cached.
* External credentials are exchanged for GCP access tokens via the token
* exchange endpoint and other settings provided in the client options
* object.
* @return A promise that resolves with the fresh GCP access tokens.
*/
protected async refreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
// Retrieve the external credential.
const subjectToken = await this.retrieveSubjectToken();
// Construct the STS credentials options.
const stsCredentialsOptions: sts.StsCredentialsOptions = {
grantType: STS_GRANT_TYPE,
audience: this.audience,
requestedTokenType: STS_REQUEST_TOKEN_TYPE,
subjectToken,
subjectTokenType: this.subjectTokenType,
scope: this.getScopesArray(),
};

// Exchange the external credentials for a GCP access token.
const stsResponse = await this.stsCredential.exchangeToken(
stsCredentialsOptions
);
// Save response in cached access token.
this.cachedAccessToken = {
access_token: stsResponse.access_token,
expiry_date: new Date().getTime() + stsResponse.expires_in * 1000,
res: stsResponse.res,
};

// Save credentials.
this.credentials = {};
Object.assign(this.credentials, this.cachedAccessToken);
delete (this.credentials as CredentialsWithResponse).res;

// Trigger tokens event to notify external listeners.
this.emit('tokens', {
refresh_token: null,
expiry_date: this.cachedAccessToken!.expiry_date,
access_token: this.cachedAccessToken!.access_token,
token_type: 'Bearer',
id_token: null,
});
// Return the cached access token.
return this.cachedAccessToken;
}

/**
* Returns whether the provided credentials are expired or not.
* If there is no expiry time, assumes the token is not expired or expiring.
* @param accessToken The credentials to check for expiration.
* @return Whether the credentials are expired or not.
*/
private isExpired(accessToken: Credentials): boolean {
const now = new Date().getTime();
return accessToken.expiry_date
? now >= accessToken.expiry_date - this.eagerRefreshThresholdMillis
: false;
}

/**
* @return The list of scopes for the requested GCP access token.
*/
private getScopesArray(): string[] | undefined {
// Since scopes can be provided as string or array, the type should
// be normalized.
return typeof this.scopes === 'string' ? [this.scopes] : this.scopes;
}
}
4 changes: 2 additions & 2 deletions synth.metadata
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
"git": {
"name": ".",
"remote": "https://github.com/googleapis/google-auth-library-nodejs.git",
"sha": "11b0cb735c47cecef939b0af934747cf9cd29e9a"
"sha": "b976c8aabac409e96aadaf3a515a03c5da8eda29"
}
},
{
"git": {
"name": "synthtool",
"remote": "https://github.com/googleapis/synthtool.git",
"sha": "89d431fb2975fc4e0ed24995a6e6dfc8ff4c24fa"
"sha": "bd0deaa1113b588d70449535ab9cbf0f2bd0e72f"
}
}
]
Expand Down
Loading

0 comments on commit 1174e4a

Please sign in to comment.