File

src/auth/externalAccountAuthorizedUserClient.ts

Description

External Account Authorized User Credentials JSON interface.

Index

Properties

Properties

audience
audience: string
Type : string
client_id
client_id: string
Type : string
client_secret
client_secret: string
Type : string
quota_project_id
quota_project_id: string
Type : string
Optional
refresh_token
refresh_token: string
Type : string
revoke_url
revoke_url: string
Type : string
Optional
token_info_url
token_info_url: string
Type : string
token_url
token_url: string
Type : string
type
type:
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 {
  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;
  }
}

results matching ""

    No results matching ""