Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add External Account Authorized User client type #1530

Merged
merged 11 commits into from
Apr 13, 2023
328 changes: 328 additions & 0 deletions src/auth/externalAccountAuthorizedUserClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
// Copyright 2023 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 {AuthClient} from './authclient';
import {Headers, RefreshOptions} from './oauth2client';
import {
ClientAuthentication,
getErrorFromOAuthErrorResponse,
OAuthClientAuthHandler,
OAuthErrorResponse,
} from './oauth2common';
import {BodyResponseCallback, DefaultTransporter} from '../transporters';
import {
GaxiosError,
GaxiosOptions,
GaxiosPromise,
GaxiosResponse,
} from 'gaxios';
import {Credentials} from './credentials';
import * as stream from 'stream';
import {EXPIRATION_TIME_OFFSET} from './baseexternalclient';

/**
* External Account Authorized User Credentials JSON interface.
*/
export interface ExternalAccountAuthorizedUserClientOptions {
type: typeof EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE;
audience: string;
client_id: string;
client_secret: string;
refresh_token: string;
token_url: string;
token_info_url: string;
revoke_url?: string;
quota_project_id?: string;
}

/**
* The credentials JSON file type for external account authorized user clients.
*/
export const EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE =
'external_account_authorized_user';

/**
* Internal interface for tracking the access token expiration time.
*/
interface CredentialsWithResponse extends Credentials {
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
res?: GaxiosResponse | null;
}

/**
* Internal interface representing the token refresh response from the token_url endpoint.
*/
interface TokenRefreshResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
res?: GaxiosResponse | null;
}

/**
* Handler for token refresh requests sent to the token_url endpoint for external
* authorized user credentials.
*/
class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler {
/**
* Initializes an ExternalAccountAuthorizedUserHandler instance.
* @param url The URL of the token refresh endpoint.
* @param transporter The transporter to use for the refresh request.
* @param clientAuthentication The client authentication credentials to use
* for the refresh request.
*/
constructor(
private readonly url: string,
private readonly transporter: DefaultTransporter,
clientAuthentication?: ClientAuthentication
) {
super(clientAuthentication);
}

/**
* Requests a new access token from the token_url endpoint using the provided
* refresh token.
* @param refreshToken The refresh token to use to generate a new access token.
* @param additionalHeaders Optional additional headers to pass along the
* request.
* @return A promise that resolves with the token refresh response containing
* the requested access token and its expiration time.
*/
async refreshToken(
refreshToken: string,
additionalHeaders?: Headers
): Promise<TokenRefreshResponse> {
const values = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});

const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
...additionalHeaders,
};

const opts: GaxiosOptions = {
url: this.url,
method: 'POST',
headers,
data: values.toString(),
responseType: 'json',
};
// Apply OAuth client authentication.
this.applyClientAuthenticationOptions(opts);

try {
const response = await this.transporter.request<TokenRefreshResponse>(
opts
);
// Successful response.
const tokenRefreshResponse = response.data;
tokenRefreshResponse.res = response;
return tokenRefreshResponse;
} catch (error) {
// Translate error to OAuthError.
if (error instanceof GaxiosError && error.response) {
throw getErrorFromOAuthErrorResponse(
error.response.data as OAuthErrorResponse,
// Preserve other fields from the original error.
error
);
}
// Request could fail before the server responds.
throw error;
}
}
}

/**
* External Account Authorized User Client. This is used for OAuth2 credentials
* sourced using external identities through Workforce Identity Federation.
* Obtaining the initial access and refresh token can be done through the
* Google Cloud CLI.
*/
export class ExternalAccountAuthorizedUserClient extends AuthClient {
private cachedAccessToken: CredentialsWithResponse | null;
private readonly externalAccountAuthorizedUserHandler: ExternalAccountAuthorizedUserHandler;
private refreshToken: string;

/**
* Instantiates an ExternalAccountAuthorizedUserClient instances using the
* provided JSON object loaded from a credentials files.
* An error is throws if the credential is not valid.
* @param options The external account authorized user option object typically
* from the external accoutn authorized user 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: ExternalAccountAuthorizedUserClientOptions,
additionalOptions?: RefreshOptions
) {
super();
this.refreshToken = options.refresh_token;
const clientAuth = {
confidentialClientType: 'basic',
clientId: options.client_id,
clientSecret: options.client_secret,
} as ClientAuthentication;
this.externalAccountAuthorizedUserHandler =
new ExternalAccountAuthorizedUserHandler(
options.token_url,
this.transporter,
clientAuth
);

this.cachedAccessToken = null;
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;
}

async getAccessToken(): Promise<{
token?: string | null;
res?: GaxiosResponse | null;
}> {
// 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,
};
}

async getRequestHeaders(): Promise<Headers> {
const accessTokenResponse = await this.getAccessToken();
const headers: Headers = {
Authorization: `Bearer ${accessTokenResponse.token}`,
};
return this.addSharedMetadataHeaders(headers);
}

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 response: GaxiosResponse;
try {
const requestHeaders = await this.getRequestHeaders();
opts.headers = opts.headers || {};
if (requestHeaders && requestHeaders['x-goog-user-project']) {
opts.headers['x-goog-user-project'] =
requestHeaders['x-goog-user-project'];
}
if (requestHeaders && requestHeaders.Authorization) {
opts.headers.Authorization = requestHeaders.Authorization;
}
response = 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 await this.requestAsync<T>(opts, true);
}
}
throw e;
}
return response;
}

/**
* Forces token refresh, even if unexpired tokens are currently cached.
* @return A promise that resolves with the refreshed credential.
*/
protected async refreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
// Refresh the access token using the refresh token.
const refreshResponse =
await this.externalAccountAuthorizedUserHandler.refreshToken(
this.refreshToken
);

this.cachedAccessToken = {
access_token: refreshResponse.access_token,
expiry_date: new Date().getTime() + refreshResponse.expires_in * 1000,
res: refreshResponse.res,
};

if (refreshResponse.refresh_token !== undefined) {
this.refreshToken = refreshResponse.refresh_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 credentials The credentials to check for expiration.
* @return Whether the credentials are expired or not.
*/
private isExpired(credentials: Credentials): boolean {
const now = new Date().getTime();
return credentials.expiry_date
? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis
: false;
}
}
11 changes: 11 additions & 0 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ import {
BaseExternalAccountClient,
} from './baseexternalclient';
import {AuthClient} from './authclient';
import {
EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE,
ExternalAccountAuthorizedUserClient,
ExternalAccountAuthorizedUserClientOptions,
} from './externalAccountAuthorizedUserClient';

/**
* Defines all types of explicit clients that are determined via ADC JSON
Expand All @@ -57,6 +62,7 @@ export type JSONClient =
| JWT
| UserRefreshClient
| BaseExternalAccountClient
| ExternalAccountAuthorizedUserClient
| Impersonated;

export interface ProjectIdCallback {
Expand Down Expand Up @@ -589,6 +595,11 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
options
)!;
client.scopes = this.getAnyScopes();
} else if (json.type === EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE) {
client = new ExternalAccountAuthorizedUserClient(
json as ExternalAccountAuthorizedUserClientOptions,
options
);
} else {
(options as JWTOptions).scopes = this.scopes;
client = new JWT(options);
Expand Down
9 changes: 9 additions & 0 deletions test/fixtures/external-account-authorized-user-cred.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "external_account_authorized_user",
"audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID",
"client_id": "clientId",
"client_secret": "clientSecret",
"refresh_token": "refreshToken",
"token_url": "https://sts.googleapis.com/v1/oauthtoken",
"token_info_url": "https://sts.googleapis.com/v1/introspect"
}