File

src/auth/downscopedclient.ts

Description

Defines an upper bound of permissions available for a GCP credential.

Index

Properties

Properties

accessBoundary
accessBoundary: literal type
Type : literal type
import {
  GaxiosError,
  GaxiosOptions,
  GaxiosPromise,
  GaxiosResponse,
} from 'gaxios';
import * as stream from 'stream';

import {BodyResponseCallback} from '../transporters';
import {Credentials} from './credentials';
import {AuthClient} from './authclient';

import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client';
import * as sts from './stscredentials';

/**
 * 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 requested token exchange subject_token_type: rfc8693#section-2.1
 */
const STS_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
/** The STS access token exchange end point. */
const STS_ACCESS_TOKEN_URL = 'https://sts.googleapis.com/v1/token';

/**
 * The maximum number of access boundary rules a Credential Access Boundary
 * can contain.
 */
export const MAX_ACCESS_BOUNDARY_RULES_COUNT = 10;

/**
 * Offset to take into account network delays and server clock skews.
 */
export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000;

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

/**
 * Internal interface for tracking and returning the Downscoped access token
 * expiration time in epoch time (seconds).
 */
interface DownscopedAccessTokenResponse extends GetAccessTokenResponse {
  expirationTime?: number | null;
}

/**
 * Defines an upper bound of permissions available for a GCP credential.
 */
export interface CredentialAccessBoundary {
  accessBoundary: {
    accessBoundaryRules: AccessBoundaryRule[];
  };
}

/** Defines an upper bound of permissions on a particular resource. */
interface AccessBoundaryRule {
  availablePermissions: string[];
  availableResource: string;
  availabilityCondition?: AvailabilityCondition;
}

/**
 * An optional condition that can be used as part of a
 * CredentialAccessBoundary to further restrict permissions.
 */
interface AvailabilityCondition {
  expression: string;
  title?: string;
  description?: string;
}

/**
 * Defines a set of Google credentials that are downscoped from an existing set
 * of Google OAuth2 credentials. This is useful to restrict the Identity and
 * Access Management (IAM) permissions that a short-lived credential can use.
 * The common pattern of usage is to have a token broker with elevated access
 * generate these downscoped credentials from higher access source credentials
 * and pass the downscoped short-lived access tokens to a token consumer via
 * some secure authenticated channel for limited access to Google Cloud Storage
 * resources.
 */
export class DownscopedClient extends AuthClient {
  private cachedDownscopedAccessToken: CredentialsWithResponse | null;
  private readonly stsCredential: sts.StsCredentials;
  public readonly eagerRefreshThresholdMillis: number;
  public readonly forceRefreshOnFailure: boolean;

  /**
   * Instantiates a downscoped client object using the provided source
   * AuthClient and credential access boundary rules.
   * To downscope permissions of a source AuthClient, a Credential Access
   * Boundary that specifies which resources the new credential can access, as
   * well as an upper bound on the permissions that are available on each
   * resource, has to be defined. A downscoped client can then be instantiated
   * using the source AuthClient and the Credential Access Boundary.
   * @param authClient The source AuthClient to be downscoped based on the
   *   provided Credential Access Boundary rules.
   * @param credentialAccessBoundary The Credential Access Boundary which
   *   contains a list of access boundary rules. Each rule contains information
   *   on the resource that the rule applies to, the upper bound of the
   *   permissions that are available on that resource and an optional
   *   condition to further restrict permissions.
   * @param additionalOptions Optional additional behavior customization
   *   options. These currently customize expiration threshold time and
   *   whether to retry on 401/403 API request errors.
   * @param quotaProjectId Optional quota project id for setting up in the
   *   x-goog-user-project header.
   */
  constructor(
    private readonly authClient: AuthClient,
    private readonly credentialAccessBoundary: CredentialAccessBoundary,
    additionalOptions?: RefreshOptions,
    quotaProjectId?: string
  ) {
    super();
    // Check 1-10 Access Boundary Rules are defined within Credential Access
    // Boundary.
    if (
      credentialAccessBoundary.accessBoundary.accessBoundaryRules.length === 0
    ) {
      throw new Error('At least one access boundary rule needs to be defined.');
    } else if (
      credentialAccessBoundary.accessBoundary.accessBoundaryRules.length >
      MAX_ACCESS_BOUNDARY_RULES_COUNT
    ) {
      throw new Error(
        'The provided access boundary has more than ' +
          `${MAX_ACCESS_BOUNDARY_RULES_COUNT} access boundary rules.`
      );
    }

    // Check at least one permission should be defined in each Access Boundary
    // Rule.
    for (const rule of credentialAccessBoundary.accessBoundary
      .accessBoundaryRules) {
      if (rule.availablePermissions.length === 0) {
        throw new Error(
          'At least one permission should be defined in access boundary rules.'
        );
      }
    }

    this.stsCredential = new sts.StsCredentials(STS_ACCESS_TOKEN_URL);
    this.cachedDownscopedAccessToken = null;
    // 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;
    this.quotaProjectId = quotaProjectId;
  }

  /**
   * Provides a mechanism to inject Downscoped access tokens directly.
   * The expiry_date field is required to facilitate determination of the token
   * expiration which would make it easier for the token consumer to handle.
   * @param credentials The Credentials object to set on the current client.
   */
  setCredentials(credentials: Credentials) {
    if (!credentials.expiry_date) {
      throw new Error(
        'The access token expiry_date field is missing in the provided ' +
          'credentials.'
      );
    }
    super.setCredentials(credentials);
    this.cachedDownscopedAccessToken = credentials;
  }

  async getAccessToken(): Promise<DownscopedAccessTokenResponse> {
    // If the cached access token is unavailable or expired, force refresh.
    // The Downscoped access token will be returned in
    // DownscopedAccessTokenResponse format.
    if (
      !this.cachedDownscopedAccessToken ||
      this.isExpired(this.cachedDownscopedAccessToken)
    ) {
      await this.refreshAccessTokenAsync();
    }
    // Return Downscoped access token in DownscopedAccessTokenResponse format.
    return {
      token: this.cachedDownscopedAccessToken!.access_token,
      expirationTime: this.cachedDownscopedAccessToken!.expiry_date,
      res: this.cachedDownscopedAccessToken!.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 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.
   * GCP access tokens are retrieved from authclient object/source credential.
   * Then GCP access tokens are exchanged for downscoped access tokens via the
   * token exchange endpoint.
   * @return A promise that resolves with the fresh downscoped access token.
   */
  protected async refreshAccessTokenAsync(): Promise<CredentialsWithResponse> {
    // Retrieve GCP access token from source credential.
    const subjectToken = (await this.authClient.getAccessToken()).token;

    // Construct the STS credentials options.
    const stsCredentialsOptions: sts.StsCredentialsOptions = {
      grantType: STS_GRANT_TYPE,
      requestedTokenType: STS_REQUEST_TOKEN_TYPE,
      subjectToken: subjectToken as string,
      subjectTokenType: STS_SUBJECT_TOKEN_TYPE,
    };

    // Exchange the source AuthClient access token for a Downscoped access
    // token.
    const stsResponse = await this.stsCredential.exchangeToken(
      stsCredentialsOptions,
      undefined,
      this.credentialAccessBoundary
    );

    /**
     * The STS endpoint will only return the expiration time for the downscoped
     * access token if the original access token represents a service account.
     * The downscoped token's expiration time will always match the source
     * credential expiration. When no expires_in is returned, we can copy the
     * source credential's expiration time.
     */
    const sourceCredExpireDate =
      this.authClient.credentials?.expiry_date || null;
    const expiryDate = stsResponse.expires_in
      ? new Date().getTime() + stsResponse.expires_in * 1000
      : sourceCredExpireDate;
    // Save response in cached access token.
    this.cachedDownscopedAccessToken = {
      access_token: stsResponse.access_token,
      expiry_date: expiryDate,
      res: stsResponse.res,
    };

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

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

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

results matching ""

    No results matching ""