diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index f434738c..2a0311b8 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-nodejs:latest - digest: sha256:da69f1fd77b825b0520b1b0a047c270a3f7e3a42e4d46a5321376281cef6e62b -# created: 2025-06-02T21:06:54.667555755Z + digest: sha256:66c44f0ad8f6caaa4eb3fbe74f8c2b4de5a97c2b930cee069e712c447723ba95 +# created: 2025-07-08T20:57:17.642848562Z diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 2c2e5207..26ab7802 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -16,6 +16,6 @@ "devDependencies": { "@octokit/rest": "^19.0.0", "mocha": "^10.0.0", - "sinon": "^18.0.0" + "sinon": "^21.0.0" } } \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 883082c0..ba80cb2c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20, 22] + node: [18, 20, 22, 24] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/issues-no-repro.yaml b/.github/workflows/issues-no-repro.yaml index 9b2f7014..816d9a70 100644 --- a/.github/workflows/issues-no-repro.yaml +++ b/.github/workflows/issues-no-repro.yaml @@ -11,7 +11,7 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 - run: npm install diff --git a/.readme-partials.yaml b/.readme-partials.yaml index 514b849a..8473de92 100644 --- a/.readme-partials.yaml +++ b/.readme-partials.yaml @@ -542,6 +542,65 @@ body: |- - `$URL_TO_GET_OIDC_TOKEN`: The URL of the local server endpoint to call to retrieve the OIDC token. - `$HEADER_KEY` and `$HEADER_VALUE`: The additional header key/value pairs to pass along the GET request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. + ### X.509 certificate-sourced credentials + For [X.509 certificate-sourced credentials](https://cloud.google.com/iam/docs/workload-identity-federation-with-x509-certificates), we use the certificate and private key cryptographic pair used to prove your application's identity. The certificate has a built-in expiration date and must be renewed before that date to maintain access. + + **Generating Configuration Files for X.509 Federation** + + To configure X.509 certificate-sourced credentials, you must generate two separate configuration files: a primary **credential configuration file** and a **certificate configuration file**. The `gcloud iam workload-identity-pools create-cred-config` command handles the creation of both. + + The location where the certificate configuration file is created depends on whether you use the `--credential-cert-configuration-output-file` flag. + + **Default Behavior (Recommended)** + + If you omit the `--credential-cert-configuration-output-file` flag, gcloud creates the certificate configuration file at a default, well-known location that client libraries can automatically discover. This is the simplest approach for most use cases. + + **Example Command (Default Behavior):** + ```bash + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --output-file /path/to/config.json + ``` + the following variables need to be substituted: + - `$PROJECT_NUMBER`: The Google Cloud project number. + - `$POOL_ID`: The workload identity pool ID. + - `$PROVIDER_ID`: The provider ID. + - `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. + - `$PATH_TO_CERTIFICATE`: The file path where your leaf X.509 certificate is located. + - `$PATH_TO_PRIVATE_KEY`: The file path where the corresponding private key (.key) for the leaf certificate is located. + - `$PATH_TO_TRUST_CHAIN`: Points to the file path of the X.509 certificate trust chain file, containing any intermediate certificates required to complete the trust chain between the leaf certificate and the trust store configured in the Workload Identity Federation pool. + + This command results in: + - `/path/to/config.json`: Created at the path you specified. This file will contain `"use_default_certificate_config": true` to instruct clients to look for the certificate configuration at the default path. + - `certificate_config.json`: Created at the default gcloud configuration path, which is typically `~/.config/gcloud/certificate_config.json` on Linux and macOS, or `%APPDATA%\gcloud\certificate_config.json` on Windows. + + **Custom Location Behavior** + + If you need to store the certificate configuration file in a non-default location, use the `--credential-cert-configuration-output-file` flag. + + **Example Command (Custom Location):** + ```bash + gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --credential-cert-configuration-output-file "/custom/path/cert_config.json" \ + --output-file /path/to/config.json + ``` + + Use the default location example as a reference to substitute placeholders. + + This command results in: + + - `/path/to/config.json`: Created at the path you specified. This file will contain a `"certificate_config_location"` field that points to your custom path. + - `cert_config.json`: Created at `/custom/path/cert_config.json`, as specified by the flag. + ### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8a771f..40e07e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ [1]: https://www.npmjs.com/package/google-auth-library?activeTab=versions +## [10.2.0](https://github.com/googleapis/google-auth-library-nodejs/compare/v10.1.0...v10.2.0) (2025-07-18) + + +### Features + +* X509 cert authentication ([#2055](https://github.com/googleapis/google-auth-library-nodejs/issues/2055)) ([6ac9ab4](https://github.com/googleapis/google-auth-library-nodejs/commit/6ac9ab4fd49d64d8315f16d7f2757da04fbeb579)) + + +### Bug Fixes + +* **deps:** Update dependency @googleapis/iam to v30 ([#2052](https://github.com/googleapis/google-auth-library-nodejs/issues/2052)) ([b8adc26](https://github.com/googleapis/google-auth-library-nodejs/commit/b8adc26657eafb6e61622e0da0035e7e791df710)) + ## [10.1.0](https://github.com/googleapis/google-auth-library-nodejs/compare/v10.0.0...v10.1.0) (2025-06-12) diff --git a/README.md b/README.md index 17ee40cb..effaf66e 100644 --- a/README.md +++ b/README.md @@ -586,6 +586,65 @@ Where the following variables need to be substituted: - `$URL_TO_GET_OIDC_TOKEN`: The URL of the local server endpoint to call to retrieve the OIDC token. - `$HEADER_KEY` and `$HEADER_VALUE`: The additional header key/value pairs to pass along the GET request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. +### X.509 certificate-sourced credentials +For [X.509 certificate-sourced credentials](https://cloud.google.com/iam/docs/workload-identity-federation-with-x509-certificates), we use the certificate and private key cryptographic pair used to prove your application's identity. The certificate has a built-in expiration date and must be renewed before that date to maintain access. + +**Generating Configuration Files for X.509 Federation** + +To configure X.509 certificate-sourced credentials, you must generate two separate configuration files: a primary **credential configuration file** and a **certificate configuration file**. The `gcloud iam workload-identity-pools create-cred-config` command handles the creation of both. + +The location where the certificate configuration file is created depends on whether you use the `--credential-cert-configuration-output-file` flag. + +**Default Behavior (Recommended)** + +If you omit the `--credential-cert-configuration-output-file` flag, gcloud creates the certificate configuration file at a default, well-known location that client libraries can automatically discover. This is the simplest approach for most use cases. + +**Example Command (Default Behavior):** +```bash +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --output-file /path/to/config.json +``` +the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$PROVIDER_ID`: The provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$PATH_TO_CERTIFICATE`: The file path where your leaf X.509 certificate is located. +- `$PATH_TO_PRIVATE_KEY`: The file path where the corresponding private key (.key) for the leaf certificate is located. +- `$PATH_TO_TRUST_CHAIN`: Points to the file path of the X.509 certificate trust chain file, containing any intermediate certificates required to complete the trust chain between the leaf certificate and the trust store configured in the Workload Identity Federation pool. + +This command results in: +- `/path/to/config.json`: Created at the path you specified. This file will contain `"use_default_certificate_config": true` to instruct clients to look for the certificate configuration at the default path. +- `certificate_config.json`: Created at the default gcloud configuration path, which is typically `~/.config/gcloud/certificate_config.json` on Linux and macOS, or `%APPDATA%\gcloud\certificate_config.json` on Windows. + +**Custom Location Behavior** + +If you need to store the certificate configuration file in a non-default location, use the `--credential-cert-configuration-output-file` flag. + +**Example Command (Custom Location):** +```bash +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-cert-path "$PATH_TO_CERTIFICATE" \ + --credential-cert-private-key-path "$PATH_TO_PRIVATE_KEY" \ + --credential-cert-trust-chain-path "$PATH_TO_TRUST_CHAIN" \ + --credential-cert-configuration-output-file "/custom/path/cert_config.json" \ + --output-file /path/to/config.json +``` + +Use the default location example as a reference to substitute placeholders. + +This command results in: + +- `/path/to/config.json`: Created at the path you specified. This file will contain a `"certificate_config_location"` field that points to your custom path. +- `cert_config.json`: Created at `/custom/path/cert_config.json`, as specified by the flag. + ### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, diff --git a/package.json b/package.json index 39bc3234..04900e56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "10.1.0", + "version": "10.2.0", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { @@ -56,7 +56,7 @@ "nock": "^14.0.1", "null-loader": "^4.0.0", "puppeteer": "^24.0.0", - "sinon": "^18.0.1", + "sinon": "^21.0.0", "ts-loader": "^8.0.0", "typescript": "^5.1.6", "webpack": "^5.21.2", diff --git a/samples/package.json b/samples/package.json index 0bb85dba..29f2accc 100644 --- a/samples/package.json +++ b/samples/package.json @@ -15,8 +15,8 @@ "dependencies": { "@google-cloud/language": "^7.0.0", "@google-cloud/storage": "^7.0.0", - "@googleapis/iam": "^28.0.0", - "google-auth-library": "^10.1.0", + "@googleapis/iam": "^30.0.0", + "google-auth-library": "^10.2.0", "node-fetch": "^2.3.0", "open": "^9.0.0", "server-destroy": "^1.0.1" diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index b1e7d61f..5c9196c4 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -229,17 +229,18 @@ export abstract class BaseExternalAccountClient extends AuthClient { * used. */ public scopes?: string | string[]; - private cachedAccessToken: CredentialsWithResponse | null; + public projectNumber: string | null; protected readonly audience: string; protected readonly subjectTokenType: string; + protected stsCredential: sts.StsCredentials; + protected readonly clientAuth?: ClientAuthentication; + protected credentialSourceType?: string; + private cachedAccessToken: CredentialsWithResponse | null; private readonly serviceAccountImpersonationUrl?: string; private readonly serviceAccountImpersonationLifetime?: number; - private readonly stsCredential: sts.StsCredentials; - private readonly clientAuth?: ClientAuthentication; private readonly workforcePoolUserProject?: string; - public projectNumber: string | null; private readonly configLifetimeRequested: boolean; - protected credentialSourceType?: string; + private readonly tokenUrl: string; /** * @example * ```ts @@ -281,7 +282,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { const clientId = opts.get('client_id'); const clientSecret = opts.get('client_secret'); - const tokenUrl = + this.tokenUrl = opts.get('token_url') ?? DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain); const subjectTokenType = opts.get('subject_token_type'); @@ -310,7 +311,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { } this.stsCredential = new sts.StsCredentials({ - tokenExchangeEndpoint: tokenUrl, + tokenExchangeEndpoint: this.tokenUrl, clientAuthentication: this.clientAuth, }); this.scopes = opts.get('scopes') || [DEFAULT_OAUTH_SCOPE]; @@ -715,4 +716,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { : 'unknown'; return `gl-node/${nodeVersion} auth/${pkg.version} google-byoid-sdk source/${credentialSourceType} sa-impersonation/${saImpersonation} config-lifetime/${this.configLifetimeRequested}`; } + + protected getTokenUrl(): string { + return this.tokenUrl; + } } diff --git a/src/auth/certificatesubjecttokensupplier.ts b/src/auth/certificatesubjecttokensupplier.ts new file mode 100644 index 00000000..d09382c3 --- /dev/null +++ b/src/auth/certificatesubjecttokensupplier.ts @@ -0,0 +1,330 @@ +// Copyright 2025 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 {SubjectTokenSupplier} from './identitypoolclient'; +import {getWellKnownCertificateConfigFileLocation, isValidFile} from '../util'; +import * as fs from 'fs'; +import {createPrivateKey, X509Certificate} from 'crypto'; +import * as https from 'https'; + +export const CERTIFICATE_CONFIGURATION_ENV_VARIABLE = + 'GOOGLE_API_CERTIFICATE_CONFIG'; + +/** + * Thrown when the certificate source cannot be located or accessed. + */ +export class CertificateSourceUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'CertificateSourceUnavailableError'; + } +} + +/** + * Thrown for invalid configuration that is not related to file availability. + */ +export class InvalidConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidConfigurationError'; + } +} + +/** + * Defines options for creating a {@link CertificateSubjectTokenSupplier}. + */ +export interface CertificateSubjectTokenSupplierOptions { + /** + * If true, uses the default well-known location for the certificate config. + * Either this or `certificateConfigLocation` must be provided. + */ + useDefaultCertificateConfig?: boolean; + /** + * The file path to the certificate configuration JSON file. + * Required if `useDefaultCertificateConfig` is not true. + */ + certificateConfigLocation?: string; + /** + * The file path to the trust chain (PEM format). + */ + trustChainPath?: string; +} + +/** + * Represents the "workload" block within the certificate configuration file. + * @internal + */ +interface WorkloadCertConfigJson { + cert_path: string; + key_path: string; +} + +/** + * Represents the structure of the certificate_config.json file. + * @internal + */ +interface CertificateConfigFileJson { + version: number; + cert_configs: { + workload?: WorkloadCertConfigJson; + }; +} + +/** + * A subject token supplier that uses a client certificate for authentication. + * It provides the certificate chain as the subject token for identity federation. + */ +export class CertificateSubjectTokenSupplier implements SubjectTokenSupplier { + private certificateConfigPath: string; + private readonly trustChainPath?: string; + private cert?: Buffer; + private key?: Buffer; + + /** + * Initializes a new instance of the CertificateSubjectTokenSupplier. + * @param opts The configuration options for the supplier. + */ + constructor(opts: CertificateSubjectTokenSupplierOptions) { + if (!opts.useDefaultCertificateConfig && !opts.certificateConfigLocation) { + throw new InvalidConfigurationError( + 'Either `useDefaultCertificateConfig` must be true or a `certificateConfigLocation` must be provided.', + ); + } + if (opts.useDefaultCertificateConfig && opts.certificateConfigLocation) { + throw new InvalidConfigurationError( + 'Both `useDefaultCertificateConfig` and `certificateConfigLocation` cannot be provided.', + ); + } + this.trustChainPath = opts.trustChainPath; + this.certificateConfigPath = opts.certificateConfigLocation ?? ''; + } + + /** + * Creates an HTTPS agent configured with the client certificate and private key for mTLS. + * @returns An mTLS-configured https.Agent. + */ + public async createMtlsHttpsAgent(): Promise { + if (!this.key || !this.cert) { + throw new InvalidConfigurationError( + 'Cannot create mTLS Agent with missing certificate or key', + ); + } + return new https.Agent({key: this.key, cert: this.cert}); + } + + /** + * Constructs the subject token, which is the base64-encoded certificate chain. + * @returns A promise that resolves with the subject token. + */ + public async getSubjectToken(): Promise { + // The "subject token" in this context is the processed certificate chain. + + this.certificateConfigPath = await this.#resolveCertificateConfigFilePath(); + + const {certPath, keyPath} = await this.#getCertAndKeyPaths(); + + ({cert: this.cert, key: this.key} = await this.#getKeyAndCert( + certPath, + keyPath, + )); + + return await this.#processChainFromPaths(this.cert); + } + + /** + * Resolves the absolute path to the certificate configuration file + * by checking the "certificate_config_location" provided in the ADC file, + * or the "GOOGLE_API_CERTIFICATE_CONFIG" environment variable + * or in the default gcloud path. + * @param overridePath An optional path to check first. + * @returns The resolved file path. + */ + async #resolveCertificateConfigFilePath(): Promise { + // 1. Check for the override path from constructor options. + const overridePath = this.certificateConfigPath; + if (overridePath) { + if (await isValidFile(overridePath)) { + return overridePath; + } + throw new CertificateSourceUnavailableError( + `Provided certificate config path is invalid: ${overridePath}`, + ); + } + + // 2. Check the standard environment variable. + const envPath = process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE]; + if (envPath) { + if (await isValidFile(envPath)) { + return envPath; + } + throw new CertificateSourceUnavailableError( + `Path from environment variable "${CERTIFICATE_CONFIGURATION_ENV_VARIABLE}" is invalid: ${envPath}`, + ); + } + + // 3. Check the well-known gcloud config location. + const wellKnownPath = getWellKnownCertificateConfigFileLocation(); + if (await isValidFile(wellKnownPath)) { + return wellKnownPath; + } + + // 4. If none are found, throw an error. + throw new CertificateSourceUnavailableError( + 'Could not find certificate configuration file. Searched override path, ' + + `the "${CERTIFICATE_CONFIGURATION_ENV_VARIABLE}" env var, and the gcloud path (${wellKnownPath}).`, + ); + } + + /** + * Reads and parses the certificate config JSON file to extract the certificate and key paths. + * @returns An object containing the certificate and key paths. + */ + async #getCertAndKeyPaths(): Promise<{ + certPath: string; + keyPath: string; + }> { + const configPath = this.certificateConfigPath; + let fileContents: string; + try { + fileContents = await fs.promises.readFile(configPath, 'utf8'); + } catch (err) { + throw new CertificateSourceUnavailableError( + `Failed to read certificate config file at: ${configPath}`, + ); + } + + try { + const config = JSON.parse(fileContents) as CertificateConfigFileJson; + const certPath = config?.cert_configs?.workload?.cert_path; + const keyPath = config?.cert_configs?.workload?.key_path; + + if (!certPath || !keyPath) { + throw new InvalidConfigurationError( + `Certificate config file (${configPath}) is missing required "cert_path" or "key_path" in the workload config.`, + ); + } + return {certPath, keyPath}; + } catch (e) { + if (e instanceof InvalidConfigurationError) throw e; + throw new InvalidConfigurationError( + `Failed to parse certificate config from ${configPath}: ${ + (e as Error).message + }`, + ); + } + } + + /** + * Reads and parses the cert and key files get their content and check valid format. + * @returns An object containing the cert content and key content in buffer format. + */ + async #getKeyAndCert( + certPath: string, + keyPath: string, + ): Promise<{ + cert: Buffer; + key: Buffer; + }> { + let cert, key; + try { + cert = await fs.promises.readFile(certPath); + new X509Certificate(cert); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CertificateSourceUnavailableError( + `Failed to read certificate file at ${certPath}: ${message}`, + ); + } + try { + key = await fs.promises.readFile(keyPath); + createPrivateKey(key); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CertificateSourceUnavailableError( + `Failed to read private key file at ${keyPath}: ${message}`, + ); + } + + return {cert, key}; + } + + /** + * Reads the leaf certificate and trust chain, combines them, + * and returns a JSON array of base64-encoded certificates. + * @returns A stringified JSON array of the certificate chain. + */ + async #processChainFromPaths(leafCertBuffer: Buffer): Promise { + const leafCert = new X509Certificate(leafCertBuffer); + + // If no trust chain is provided, just use the successfully parsed leaf certificate. + if (!this.trustChainPath) { + return JSON.stringify([leafCert.raw.toString('base64')]); + } + + // Handle the trust chain logic. + try { + const chainPems = await fs.promises.readFile(this.trustChainPath, 'utf8'); + + const pemBlocks = + chainPems.match( + /-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g, + ) ?? []; + + const chainCerts: X509Certificate[] = pemBlocks.map((pem, index) => { + try { + return new X509Certificate(pem); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Throw a more precise error if a single certificate in the chain is invalid. + throw new InvalidConfigurationError( + `Failed to parse certificate at index ${index} in trust chain file ${ + this.trustChainPath + }: ${message}`, + ); + } + }); + + const leafIndex = chainCerts.findIndex(chainCert => + leafCert.raw.equals(chainCert.raw), + ); + + let finalChain: X509Certificate[]; + + if (leafIndex === -1) { + // Leaf not found, so prepend it to the chain. + finalChain = [leafCert, ...chainCerts]; + } else if (leafIndex === 0) { + // Leaf is already the first element, so the chain is correctly ordered. + finalChain = chainCerts; + } else { + // Leaf is in the chain but not at the top, which is invalid. + throw new InvalidConfigurationError( + `Leaf certificate exists in the trust chain but is not the first entry (found at index ${leafIndex}).`, + ); + } + + return JSON.stringify( + finalChain.map(cert => cert.raw.toString('base64')), + ); + } catch (err) { + // Re-throw our specific configuration errors. + if (err instanceof InvalidConfigurationError) throw err; + + const message = err instanceof Error ? err.message : String(err); + throw new CertificateSourceUnavailableError( + `Failed to process certificate chain from ${this.trustChainPath}: ${message}`, + ); + } + } +} diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index bdc24ac7..743fc990 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -20,6 +20,9 @@ import { import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; import {FileSubjectTokenSupplier} from './filesubjecttokensupplier'; import {UrlSubjectTokenSupplier} from './urlsubjecttokensupplier'; +import {CertificateSubjectTokenSupplier} from './certificatesubjecttokensupplier'; +import {StsCredentials} from './stscredentials'; +import {Gaxios} from 'gaxios'; export type SubjectTokenFormatType = 'json' | 'text'; @@ -58,13 +61,13 @@ export interface IdentityPoolClientOptions */ credential_source?: { /** - * The file location to read the subject token from. Either this or a URL - * should be specified. + * The file location to read the subject token from. Either this, a URL + * or a certificate location should be specified. */ file?: string; /** - * The URL to call to retrieve the subject token. Either this or a file - * location should be specified. + * The URL to call to retrieve the subject token. Either this, a file + * location or a certificate location should be specified. */ url?: string; /** @@ -87,6 +90,42 @@ export interface IdentityPoolClientOptions */ subject_token_field_name?: string; }; + + /** + * The certificate location to call to retrieve the subject token. Either this, a file + * location, or an url should be specified. + * @example + * File Format: + * ```json + * { + * "cert_configs": { + * "workload": { + * "key_path": "$PATH_TO_LEAF_KEY", + * "cert_path": "$PATH_TO_LEAF_CERT" + * } + * } + * } + * ``` + */ + certificate?: { + /** + * Specify whether the certificate config should be used from the default location. + * Either this or the certificate_config_location must be provided. + * The certificate config file must be in the following JSON format: + */ + use_default_certificate_config?: boolean; + /** + * Location to fetch certificate config from in case default config is not to be used. + * Either this or use_default_certificate_config=true should be provided. + */ + certificate_config_location?: string; + /** + * TrustChainPath specifies the path to a PEM-formatted file containing the X.509 certificate trust chain. + * The file should contain any intermediate certificates needed to connect + * the mTLS leaf certificate to a root certificate in the trust store. + */ + trust_chain_path?: string; + }; }; /** * The subject token supplier to call to retrieve the subject token to exchange @@ -162,19 +201,20 @@ export class IdentityPoolClient extends BaseExternalAccountClient { const file = credentialSourceOpts.get('file'); const url = credentialSourceOpts.get('url'); + const certificate = credentialSourceOpts.get('certificate'); const headers = credentialSourceOpts.get('headers'); - if (file && url) { + if ((file && url) || (url && certificate) || (file && certificate)) { throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.', + 'No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.', ); - } else if (file && !url) { + } else if (file) { this.credentialSourceType = 'file'; this.subjectTokenSupplier = new FileSubjectTokenSupplier({ filePath: file, formatType: formatType, subjectTokenFieldName: formatSubjectTokenFieldName, }); - } else if (!file && url) { + } else if (url) { this.credentialSourceType = 'url'; this.subjectTokenSupplier = new UrlSubjectTokenSupplier({ url: url, @@ -183,9 +223,19 @@ export class IdentityPoolClient extends BaseExternalAccountClient { headers: headers, additionalGaxiosOptions: IdentityPoolClient.RETRY_CONFIG, }); + } else if (certificate) { + this.credentialSourceType = 'certificate'; + const certificateSubjecttokensupplier = + new CertificateSubjectTokenSupplier({ + useDefaultCertificateConfig: + certificate.use_default_certificate_config, + certificateConfigLocation: certificate.certificate_config_location, + trustChainPath: certificate.trust_chain_path, + }); + this.subjectTokenSupplier = certificateSubjecttokensupplier; } else { throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.', + 'No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.', ); } } @@ -198,6 +248,25 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * @return A promise that resolves with the external subject token. */ async retrieveSubjectToken(): Promise { - return this.subjectTokenSupplier.getSubjectToken(this.supplierContext); + const subjectToken = await this.subjectTokenSupplier.getSubjectToken( + this.supplierContext, + ); + + if (this.subjectTokenSupplier instanceof CertificateSubjectTokenSupplier) { + const mtlsAgent = await this.subjectTokenSupplier.createMtlsHttpsAgent(); + + this.stsCredential = new StsCredentials({ + tokenExchangeEndpoint: this.getTokenUrl(), + clientAuthentication: this.clientAuth, + transporter: new Gaxios({agent: mtlsAgent}), + }); + + this.transporter = new Gaxios({ + ...(this.transporter.defaults || {}), + agent: mtlsAgent, + }); + } + + return subjectToken; } } diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 99e0398d..138e66c4 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -821,7 +821,9 @@ export class OAuth2Client extends AuthClient { ...OAuth2Client.RETRY_CONFIG, method: 'POST', url, - data: new URLSearchParams(removeUndefinedValuesInObject(data)), + data: new URLSearchParams( + removeUndefinedValuesInObject(data) as Record, + ), }; AuthClient.setMethodName(opts, 'refreshTokenNoCache'); diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts index 0beaf3ca..625f7731 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -210,7 +210,9 @@ export class StsCredentials extends OAuthClientAuthHandler { url: this.#tokenExchangeEndpoint.toString(), method: 'POST', headers, - data: new URLSearchParams(removeUndefinedValuesInObject(values)), + data: new URLSearchParams( + removeUndefinedValuesInObject(values) as Record, + ), }; AuthClient.setMethodName(opts, 'exchangeToken'); diff --git a/src/util.ts b/src/util.ts index 99a01184..238ab604 100644 --- a/src/util.ts +++ b/src/util.ts @@ -12,6 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +import * as fs from 'fs'; +import * as os from 'os'; +import path = require('path'); + +const WELL_KNOWN_CERTIFICATE_CONFIG_FILE = 'certificate_config.json'; +const CLOUDSDK_CONFIG_DIRECTORY = 'gcloud'; + /** * A utility for converting snake_case to camelCase. * @@ -241,8 +248,10 @@ export class LRUCache { } // Given and object remove fields where value is undefined. -export function removeUndefinedValuesInObject(object: {[key: string]: any}): { - [key: string]: any; +export function removeUndefinedValuesInObject(object: { + [key: string]: unknown; +}): { + [key: string]: unknown; } { Object.entries(object).forEach(([key, value]) => { if (value === undefined || value === 'undefined') { @@ -251,3 +260,43 @@ export function removeUndefinedValuesInObject(object: {[key: string]: any}): { }); return object; } + +/** + * Helper to check if a path points to a valid file. + */ +export async function isValidFile(filePath: string): Promise { + try { + const stats = await fs.promises.lstat(filePath); + return stats.isFile(); + } catch (e) { + return false; + } +} + +/** + * Determines the well-known gcloud location for the certificate config file. + * @returns The platform-specific path to the configuration file. + * @internal + */ +export function getWellKnownCertificateConfigFileLocation(): string { + const configDir = + process.env.CLOUDSDK_CONFIG || + (_isWindows() + ? path.join(process.env.APPDATA || '', CLOUDSDK_CONFIG_DIRECTORY) + : path.join( + process.env.HOME || '', + '.config', + CLOUDSDK_CONFIG_DIRECTORY, + )); + + return path.join(configDir, WELL_KNOWN_CERTIFICATE_CONFIG_FILE); +} + +/** + * Checks if the current operating system is Windows. + * @returns True if the OS is Windows, false otherwise. + * @internal + */ +function _isWindows(): boolean { + return os.platform().startsWith('win'); +} diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index 09ca6ffa..f9517327 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -51,6 +51,7 @@ const defaultProjectNumber = '123456'; const poolId = 'POOL_ID'; const providerId = 'PROVIDER_ID'; const baseUrl = 'https://sts.googleapis.com'; +const baseMtlsUrl = 'https://sts.mtls.googleapis.com'; const path = '/v1/token'; export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; const saBaseUrl = 'https://iamcredentials.googleapis.com'; @@ -112,6 +113,10 @@ export function getTokenUrl(): string { return `${baseUrl}${path}`; } +export function getMtlsTokenUrl(): string { + return `${baseMtlsUrl}${path}`; +} + export function getServiceAccountImpersonationUrl(): string { return `${saBaseUrl}${saPath}`; } diff --git a/test/fixtures/external-account-cert/cert_config.json b/test/fixtures/external-account-cert/cert_config.json new file mode 100644 index 00000000..b5e16c88 --- /dev/null +++ b/test/fixtures/external-account-cert/cert_config.json @@ -0,0 +1,8 @@ +{ + "cert_configs": { + "workload": { + "key_path": "./test/fixtures/external-account-cert/leaf.key", + "cert_path": "./test/fixtures/external-account-cert/leaf.crt" + } + } +} \ No newline at end of file diff --git a/test/fixtures/external-account-cert/cert_config_empty.json b/test/fixtures/external-account-cert/cert_config_empty.json new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/external-account-cert/cert_config_missing_cert_path.json b/test/fixtures/external-account-cert/cert_config_missing_cert_path.json new file mode 100644 index 00000000..a9607e7f --- /dev/null +++ b/test/fixtures/external-account-cert/cert_config_missing_cert_path.json @@ -0,0 +1,7 @@ +{ + "cert_configs": { + "workload": { + "key_path": "./test/fixtures/external-account-cert/leaf.key" + } + } +} \ No newline at end of file diff --git a/test/fixtures/external-account-cert/cert_config_missing_key_path.json b/test/fixtures/external-account-cert/cert_config_missing_key_path.json new file mode 100644 index 00000000..4dd9d40c --- /dev/null +++ b/test/fixtures/external-account-cert/cert_config_missing_key_path.json @@ -0,0 +1,7 @@ +{ + "cert_configs": { + "workload": { + "cert_path": "./test/fixtures/external-account-cert/leaf.crt" + } + } +} \ No newline at end of file diff --git a/test/fixtures/external-account-cert/cert_config_with_malformed_key.json b/test/fixtures/external-account-cert/cert_config_with_malformed_key.json new file mode 100644 index 00000000..c2321317 --- /dev/null +++ b/test/fixtures/external-account-cert/cert_config_with_malformed_key.json @@ -0,0 +1,8 @@ +{ + "cert_configs": { + "workload": { + "key_path": "./test/fixtures/external-account-cert/leaf_malformed.key", + "cert_path": "./test/fixtures/external-account-cert/leaf.crt" + } + } +} \ No newline at end of file diff --git a/test/fixtures/external-account-cert/cert_config_with_malformed_leaf_cert.json b/test/fixtures/external-account-cert/cert_config_with_malformed_leaf_cert.json new file mode 100644 index 00000000..e75e80e7 --- /dev/null +++ b/test/fixtures/external-account-cert/cert_config_with_malformed_leaf_cert.json @@ -0,0 +1,8 @@ +{ + "cert_configs": { + "workload": { + "key_path": "./test/fixtures/external-account-cert/leaf.key", + "cert_path": "./test/fixtures/external-account-cert/leaf_malformed.crt" + } + } +} \ No newline at end of file diff --git a/test/fixtures/external-account-cert/chain_with_leaf_middle.pem b/test/fixtures/external-account-cert/chain_with_leaf_middle.pem new file mode 100644 index 00000000..ab50d0d1 --- /dev/null +++ b/test/fixtures/external-account-cert/chain_with_leaf_middle.pem @@ -0,0 +1,52 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/chain_with_leaf_top.pem b/test/fixtures/external-account-cert/chain_with_leaf_top.pem new file mode 100644 index 00000000..97b65f56 --- /dev/null +++ b/test/fixtures/external-account-cert/chain_with_leaf_top.pem @@ -0,0 +1,52 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/chain_with_malformed_cert.pem b/test/fixtures/external-account-cert/chain_with_malformed_cert.pem new file mode 100644 index 00000000..20a6212d --- /dev/null +++ b/test/fixtures/external-account-cert/chain_with_malformed_cert.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +abbccssddeexx +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDPDCCAiSgAwIBAgIIFJsPvyc/ZSUwDQYJKoZIhvcNAQEFBQAwQTE/MD0GA1UE +AxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2VydmljZWFj +Y291bnQuY29tMB4XDTIwMDQwMjIyMjIxN1oXDTIyMDUwMTEzNTYxNVowQTE/MD0G +A1UEAxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2Vydmlj +ZWFjY291bnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6Yys +P5LIa1rRxQY93FXIJDzq6Tai4VuetffJbltRtYbdwC5Vyl99O2zoVdRlg+iYXK5B +b6kidjmWOf0kNimQ5FwYvu+xsm6w8vjL/XShkHEKiURszyCua8wvLeGVCiGBg/XU +DOgYMjzRIH5fTuj3PTZk4sMj02ZCpCQEMQ6ogpLXjaLp3ZXtFhkuHyCxVYbTRr+k +GU86JAg4XwD6AdC349v+8FEQD7YtJezUAAKEgXh9e5UeL5CpOo3Vsdv/yEVo00jh +YuWzLM6Oxt55WAhiD29vKrm7VQPSr1XwwqpdyFL2BlmqyTlb3amwvc9qv2kojGvM +SUqgS83dc0jFqtMvEQIDAQABozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE +AwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEA +m3XUMKOtXdpw0oRjykLHdYzTHHFjQTMmhWD172rsxSzwpkFoErAC7bnmEvpcRU7D +r4M+pE5VuDJ64J3lEpAh7W0zMXezPtGyWr39hVxL3vp3nh4CbCzzkUCfFvBOFFhm +OI9qnjtMtaozoGi5zLs5jEaFmgR3wfij9KQjNGZJxAg0ZkwcSNb76qOCG1/vG5au +4UuoIaq8WqSxMqBF/g+NrAE2PZhjNGnUwFPTre3SyR0otYDzJfmpL/tp5VDie8hM +L5UZU/CmZk46+T9VbvnZ5mkPAjGiPumiptO5iliBOHPtPdn8VrP+aSQM1btHA094 +1HwfbFp7pZHBUn9COAP/1Q== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/chain_with_no_leaf.pem b/test/fixtures/external-account-cert/chain_with_no_leaf.pem new file mode 100644 index 00000000..aab594f0 --- /dev/null +++ b/test/fixtures/external-account-cert/chain_with_no_leaf.pem @@ -0,0 +1,53 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAPBsLZmNGfKtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTYwOTIxMDI0NTEyWhcNMTYxMDIxMDI0NTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAsiMC7mTsmUXwZoYlT4aHY1FLw8bxIXC+z3IqA+TY1WqfbeiZRo8MA5Zx +lTTxYMKPCZUE1XBc7jvD8GJhWIj6pToPYHn73B01IBkLBxq4kF1yV2Z7DVmkvc6H +EcxXXq8zkCx0j6XOfiI4+qkXnuQn8cvrk8xfhtnMMZM7iVm6VSN93iRP/8ey6xuL +XTHrDX7ukoRce1hpT8O+15GXNrY0irhhYQz5xKibNCJF3EjV28WMry8y7I8uYUFU +RWDiQawwK9ec1zhZ94v92+GZDlPevmcFmSERKYQ0NsKcT0Y3lGuGnaExs8GyOpnC +oksu4YJGXQjg7lkv4MxzsNbRqmCkUwxw1Mg6FP0tsCNsw9qTrkvWCRA9zp/aU+sZ +IBGh1t4UGCub8joeQFvHxvr/3F7mH/dyvCjA34u0Lo1VPx+jYUIi9i0odltMspDW +xOpjqdGARZYmlJP5Au9q5cQjPMcwS/EBIb8cwNl32mUE6WnFlep+38mNR/FghIjO +ViAkXuKQmcHe6xppZAoHFsO/t3l4Tjek5vNW7erI1rgrFku/fvkIW/G8V1yIm/+Q +F+CE4maQzCJfhftpkhM/sPC/FuLNBmNE8BHVX8y58xG4is/cQxL4Z9TsFIw0C5+3 +uTrFW9D0agysahMVzPGtCqhDQqJdIJrBQqlS6bztpzBA8zEI0skCAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUz/8FmW6TfqXyNJZr7rhc+Tn5sKQwdQYDVR0jBG4wbIAUz/8F +mW6TfqXyNJZr7rhc+Tn5sKShSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDw +bC2ZjRnyrTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCQmrcfhurX +riR3Q0Y+nq040/3dJIAJXjyI9CEtxaU0nzCNTng7PwgZ0CKmCelQfInuwWFwBSHS +6kBfC1rgJeFnjnTt8a3RCgRlIgUr9NCdPSEccB7TurobwPJ2h6cJjjR8urcb0CXh +CEMvPneyPj0xUFY8vVKXMGWahz/kyfwIiVqcX/OtMZ29fUu1onbWl71g2gVLtUZl +sECdZ+AC/6HDCVpYIVETMl1T7N/XyqXZQiDLDNRDeZhnapz8w9fsW1KVujAZLNQR +pVnw2qa2UK1dSf2FHX+lQU5mFSYM4vtwaMlX/LgfdLZ9I796hFh619WwTVz+LO2N +vHnwBMabld3XSPuZRqlbBulDQ07Vbqdjv8DYSLA2aKI4ZkMMKuFLG/oS28V2ZYmv +/KpGEs5UgKY+P9NulYpTDwCU/6SomuQpP795wbG6sm7Hzq82r2RmB61GupNRGeqi +pXKsy69T388zBxYu6zQrosXiDl5YzaViH7tm0J7opye8dCWjjpnahki0vq2znti7 +6cWla2j8Xz1glvLz+JI/NCOMfxUInb82T7ijo80N0VJ2hzf7p2GxRZXAxAV9knLI +nM4F5TLjSd7ZhOOZ7ni/eZFueTMisWfypt2nc41whGjHMX/Zp1kPfhB4H2bLKIX/ +lSrwNr3qbGTEJX8JqpDBNVAd96XkMvDNyA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDPDCCAiSgAwIBAgIIFJsPvyc/ZSUwDQYJKoZIhvcNAQEFBQAwQTE/MD0GA1UE +AxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2VydmljZWFj +Y291bnQuY29tMB4XDTIwMDQwMjIyMjIxN1oXDTIyMDUwMTEzNTYxNVowQTE/MD0G +A1UEAxM2aW50ZWdyYXRpb24tdGVzdHMuY2hpbmdvci10ZXN0LmlhbS5nc2Vydmlj +ZWFjY291bnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6Yys +P5LIa1rRxQY93FXIJDzq6Tai4VuetffJbltRtYbdwC5Vyl99O2zoVdRlg+iYXK5B +b6kidjmWOf0kNimQ5FwYvu+xsm6w8vjL/XShkHEKiURszyCua8wvLeGVCiGBg/XU +DOgYMjzRIH5fTuj3PTZk4sMj02ZCpCQEMQ6ogpLXjaLp3ZXtFhkuHyCxVYbTRr+k +GU86JAg4XwD6AdC349v+8FEQD7YtJezUAAKEgXh9e5UeL5CpOo3Vsdv/yEVo00jh +YuWzLM6Oxt55WAhiD29vKrm7VQPSr1XwwqpdyFL2BlmqyTlb3amwvc9qv2kojGvM +SUqgS83dc0jFqtMvEQIDAQABozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQE +AwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEA +m3XUMKOtXdpw0oRjykLHdYzTHHFjQTMmhWD172rsxSzwpkFoErAC7bnmEvpcRU7D +r4M+pE5VuDJ64J3lEpAh7W0zMXezPtGyWr39hVxL3vp3nh4CbCzzkUCfFvBOFFhm +OI9qnjtMtaozoGi5zLs5jEaFmgR3wfij9KQjNGZJxAg0ZkwcSNb76qOCG1/vG5au +4UuoIaq8WqSxMqBF/g+NrAE2PZhjNGnUwFPTre3SyR0otYDzJfmpL/tp5VDie8hM +L5UZU/CmZk46+T9VbvnZ5mkPAjGiPumiptO5iliBOHPtPdn8VrP+aSQM1btHA094 +1HwfbFp7pZHBUn9COAP/1Q== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/leaf.crt b/test/fixtures/external-account-cert/leaf.crt new file mode 100644 index 00000000..4219c297 --- /dev/null +++ b/test/fixtures/external-account-cert/leaf.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/leaf.key b/test/fixtures/external-account-cert/leaf.key new file mode 100644 index 00000000..d283ef5e --- /dev/null +++ b/test/fixtures/external-account-cert/leaf.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i +kv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj +79Xc3yBDfKP2IeyYQIFe0t0zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kH +voa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw4Az2ZkmeuN6Fk/y9H+L +cb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB +/GrCtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ry +JR7GLbCzxPnJm/oQJBANwOCO6D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS +2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrPSXagBxzp8PecbaCHjz +NRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAk +AutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE +80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEi +Ultk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jq +wlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAz +MDjCQ== +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/leaf_malformed.crt b/test/fixtures/external-account-cert/leaf_malformed.crt new file mode 100644 index 00000000..4a16756a --- /dev/null +++ b/test/fixtures/external-account-cert/leaf_malformed.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +fnksjdaksdakvbashjbvhjasdj +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/fixtures/external-account-cert/leaf_malformed.key b/test/fixtures/external-account-cert/leaf_malformed.key new file mode 100644 index 00000000..717e6ead --- /dev/null +++ b/test/fixtures/external-account-cert/leaf_malformed.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +udsbfjkryerkjnkdfjajdakjffasdasdvasjdhasfudsbjfdfbja +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index c9c5be41..4e7b6586 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -29,7 +29,7 @@ import { getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; import {AwsSecurityCredentials} from '../src/auth/awsrequestsigner'; - +import {TestUtils} from './utils'; nock.disableNetConnect(); const ONE_HOUR_IN_SECS = 3600; @@ -175,7 +175,7 @@ describe('AwsClient', () => { ); beforeEach(() => { - clock = sinon.useFakeTimers(referenceDate); + clock = TestUtils.useFakeTimers(sinon, referenceDate); }); afterEach(() => { diff --git a/test/test.awsrequestsigner.ts b/test/test.awsrequestsigner.ts index 7e3e4737..69ae3277 100644 --- a/test/test.awsrequestsigner.ts +++ b/test/test.awsrequestsigner.ts @@ -17,7 +17,7 @@ import {describe, it, afterEach, beforeEach} from 'mocha'; import * as sinon from 'sinon'; import {AwsRequestSigner} from '../src/auth/awsrequestsigner'; import {GaxiosOptions} from 'gaxios'; - +import {TestUtils} from './utils'; /** Defines the interface to facilitate testing of AWS request signing. */ interface AwsRequestSignerTest { // Test description. @@ -41,7 +41,7 @@ describe('AwsRequestSigner', () => { const token = awsSecurityCredentials.Token; beforeEach(() => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); }); afterEach(() => { diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index e0820cba..c3f7444c 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -40,7 +40,7 @@ import { getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; - +import {TestUtils} from './utils'; nock.disableNetConnect(); interface SampleResponse { @@ -965,7 +965,7 @@ describe('BaseExternalAccountClient', () => { }); it('should force refresh when cached credential is expired', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); const emittedEvents: Credentials[] = []; const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; @@ -1063,7 +1063,8 @@ describe('BaseExternalAccountClient', () => { }); it('should respect provided eagerRefreshThresholdMillis', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); + const customThresh = 10 * 1000; const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; @@ -1381,7 +1382,7 @@ describe('BaseExternalAccountClient', () => { }); it('should force refresh when cached credential is expired', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); const emittedEvents: Credentials[] = []; const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; @@ -1505,7 +1506,7 @@ describe('BaseExternalAccountClient', () => { }); it('should respect provided eagerRefreshThresholdMillis', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); const customThresh = 10 * 1000; const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; @@ -2540,7 +2541,7 @@ describe('BaseExternalAccountClient', () => { describe('setCredentials()', () => { it('should allow injection of GCP access tokens directly', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); const credentials = { access_token: 'INJECTED_ACCESS_TOKEN', // Simulate token expires in 10mins. @@ -2581,7 +2582,7 @@ describe('BaseExternalAccountClient', () => { }); it('should not expire injected creds with no expiry_date', async () => { - clock = sinon.useFakeTimers(0); + clock = TestUtils.useFakeTimers(sinon); const credentials = { access_token: 'INJECTED_ACCESS_TOKEN', }; diff --git a/test/test.downscopedclient.ts b/test/test.downscopedclient.ts index 1cd259a9..2eec6094 100644 --- a/test/test.downscopedclient.ts +++ b/test/test.downscopedclient.ts @@ -32,7 +32,7 @@ import { getErrorFromOAuthErrorResponse, } from '../src/auth/oauth2common'; import {GetAccessTokenResponse} from '../src/auth/authclient'; - +import {TestUtils} from './utils'; nock.disableNetConnect(); /** A dummy class used as source credential for testing. */ @@ -376,7 +376,7 @@ describe('DownscopedClient', () => { describe('getAccessToken()', () => { it('should return current unexpired cached DownscopedClient access token', async () => { const now = new Date().getTime(); - clock = sinon.useFakeTimers(now); + clock = TestUtils.useFakeTimers(sinon, now); const credentials = { access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', expiry_date: now + ONE_HOUR_IN_SECS * 1000, @@ -415,7 +415,7 @@ describe('DownscopedClient', () => { it('should refresh a new DownscopedClient access when cached one gets expired', async () => { const now = new Date().getTime(); - clock = sinon.useFakeTimers(now); + clock = TestUtils.useFakeTimers(sinon, now); const emittedEvents: Credentials[] = []; const credentials = { access_token: 'DOWNSCOPED_CLIENT_ACCESS_TOKEN', diff --git a/test/test.executableresponse.ts b/test/test.executableresponse.ts index 778aa75a..2ab638c5 100644 --- a/test/test.executableresponse.ts +++ b/test/test.executableresponse.ts @@ -25,6 +25,7 @@ import { ExecutableResponseJson, } from '../src/auth/executable-response'; import * as sinon from 'sinon'; +import {TestUtils} from './utils'; const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; @@ -35,7 +36,7 @@ describe('ExecutableResponse', () => { const referenceTime = 1653429377000; beforeEach(() => { - clock = sinon.useFakeTimers({now: referenceTime}); + clock = TestUtils.useFakeTimers(sinon, referenceTime); }); afterEach(() => { diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index 054d4307..e65aa213 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -30,6 +30,7 @@ import { OAuthErrorResponse, } from '../src/auth/oauth2common'; import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; +import {TestUtils} from './utils'; nock.disableNetConnect(); @@ -110,7 +111,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { expires_in: 3600, }; beforeEach(() => { - clock = sinon.useFakeTimers(referenceDate); + clock = TestUtils.useFakeTimers(sinon, referenceDate); }); afterEach(() => { diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index 0c9a06ed..63ac15bd 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -32,7 +32,16 @@ import { mockGenerateAccessToken, mockStsTokenExchange, getExpectedExternalAccountMetricsHeaderValue, + getMtlsTokenUrl, } from './externalclienthelper'; +import {X509Certificate} from 'crypto'; +import { + CERTIFICATE_CONFIGURATION_ENV_VARIABLE, + CertificateSourceUnavailableError, + InvalidConfigurationError, +} from '../src/auth/certificatesubjecttokensupplier'; +import * as sinon from 'sinon'; +import * as util from '../src/util'; nock.disableNetConnect(); @@ -157,6 +166,26 @@ describe('IdentityPoolClient', () => { }, }, }; + const certSubjectToken = JSON.stringify([ + new X509Certificate( + fs.readFileSync( + './test/fixtures/external-account-cert/leaf.crt', + 'utf-8', + ), + ).raw.toString('base64'), + ]); + const certificateSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: + './test/fixtures/external-account-cert/cert_config.json', + }, + }, + }; const jsonRespUrlSourcedOptionsWithSA = Object.assign( { service_account_impersonation_url: getServiceAccountImpersonationUrl(), @@ -203,9 +232,9 @@ describe('IdentityPoolClient', () => { 'credentials.', ); - it('should throw when neither file or url sources are provided', () => { + it('should throw when neither file or url or certificate sources are provided', () => { const expectedError = new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.', + 'No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.', ); const invalidOptions = { type: 'external_account', @@ -224,9 +253,9 @@ describe('IdentityPoolClient', () => { }, expectedError); }); - it('should throw when both file and url options are provided', () => { + it('should throw when more than 1 of file, url or certificate options are provided', () => { const expectedError = new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.', + 'No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.', ); const invalidOptions = { type: 'external_account', @@ -1396,6 +1425,440 @@ describe('IdentityPoolClient', () => { }); }); }); + + describe('for certificate-sourced subject tokens', () => { + const orgCertConfigVar = + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE]; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + // Restore the original value after each test case. + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE] = orgCertConfigVar; + sandbox.restore(); + }); + + describe('retrieveSubjectToken()', () => { + it('should resolve when a valid cert_config file is provided', async () => { + const client = new IdentityPoolClient(certificateSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, certSubjectToken); + }); + + it('should fail when neither default location is enabled not certificate config location is provided', async () => { + const expectedError = new InvalidConfigurationError( + 'Either `useDefaultCertificateConfig` must be true or a `certificateConfigLocation` must be provided.', + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: {}, + }, + }; + assert.throws(() => { + new IdentityPoolClient(certificateSourcedOptionsWrong); + }, expectedError); + }); + + it('should fail when default location is enabled and certificate config location is provided', async () => { + const expectedError = new InvalidConfigurationError( + 'Both `useDefaultCertificateConfig` and `certificateConfigLocation` cannot be provided.', + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + use_default_certificate_config: true, + certificate_config_location: + './test/fixtures/external-account-cert/cert_config.json', + }, + }, + }; + assert.throws(() => { + new IdentityPoolClient(certificateSourcedOptionsWrong); + }, expectedError); + }); + + it('should throw when invalid cert_config path is provided', async () => { + const overridePath = 'abc/efg'; + const expectedError = new CertificateSourceUnavailableError( + `Provided certificate config path is invalid: ${overridePath}`, + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: overridePath, + }, + }, + }; + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should check GOOGLE_API_CERTIFICATE_CONFIG path for file', async () => { + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE] = + './test/fixtures/external-account-cert/cert_config.json'; + const certOptionsEnvVar: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + use_default_certificate_config: true, + }, + }, + }; + const client = new IdentityPoolClient(certOptionsEnvVar); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, certSubjectToken); + }); + + it('should throw in case default location is enabled and invalid GOOGLE_API_CERTIFICATE_CONFIG path', async () => { + const wrongPath = 'abc/efg'; + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE] = wrongPath; + const wrongCertOptionsEnvVar: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + use_default_certificate_config: true, + }, + }, + }; + const expectedError = new CertificateSourceUnavailableError( + `Path from environment variable "${CERTIFICATE_CONFIGURATION_ENV_VARIABLE}" is invalid: ${wrongPath}`, + ); + const client = new IdentityPoolClient(wrongCertOptionsEnvVar); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should access well known certificate config location', async () => { + const mockPath = + './test/fixtures/external-account-cert/cert_config.json'; + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE] = ''; + const certOptionsDefault: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + use_default_certificate_config: true, + }, + }, + }; + const getLocationStub = sandbox.stub( + util, + 'getWellKnownCertificateConfigFileLocation', + ); + getLocationStub.returns(mockPath); + const client = new IdentityPoolClient(certOptionsDefault); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, certSubjectToken); + }); + + it('should throw in case default location is enabled and well known location is invalid', async () => { + const wrongPath = 'abc/efg'; + process.env[CERTIFICATE_CONFIGURATION_ENV_VARIABLE] = ''; + const wrongCertOptionsEnvVar: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + use_default_certificate_config: true, + }, + }, + }; + const expectedError = new CertificateSourceUnavailableError( + 'Could not find certificate configuration file. Searched override path, ' + + `the "${CERTIFICATE_CONFIGURATION_ENV_VARIABLE}" env var, and the gcloud path (${wrongPath}).`, + ); + + const getLocationStub = sandbox.stub( + util, + 'getWellKnownCertificateConfigFileLocation', + ); + getLocationStub.returns(wrongPath); + const client = new IdentityPoolClient(wrongCertOptionsEnvVar); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw in case cert config has missing key path', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config_missing_key_path.json'; + const expectedError = new InvalidConfigurationError( + `Certificate config file (${certConfigPath}) is missing required "cert_path" or "key_path" in the workload config.`, + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + }, + }, + }; + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw in case cert config has missing cert path', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config_missing_cert_path.json'; + const expectedError = new InvalidConfigurationError( + `Certificate config file (${certConfigPath}) is missing required "cert_path" or "key_path" in the workload config.`, + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + }, + }, + }; + + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw in case cert config is empty or malformed', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config_empty.json'; + const expectedError = new RegExp( + `Failed to parse certificate config from ${certConfigPath}`, + ); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + }, + }, + }; + + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw if cert has invalid PEM format', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config_with_malformed_leaf_cert.json'; + const expectedError = new RegExp('Failed to read certificate file'); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + }, + }, + }; + + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw if key has invalid private key format', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config_with_malformed_key.json'; + const expectedError = new RegExp('Failed to read private key file'); + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + }, + }, + }; + + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should throw if trust chain path is invalid', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config.json'; + const certificateSourcedOptionsWrong: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + trust_chain_path: 'abc/efg', + }, + }, + }; + const client = new IdentityPoolClient(certificateSourcedOptionsWrong); + + await assert.rejects( + client.retrieveSubjectToken(), + CertificateSourceUnavailableError, + ); + }); + + it('should return subject token when leaf cert is on top of trust chain', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config.json'; + const trustChainPath = + './test/fixtures/external-account-cert/chain_with_leaf_top.pem'; + const certificateSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + trust_chain_path: trustChainPath, + }, + }, + }; + const chainPems = fs.readFileSync(trustChainPath, 'utf8'); + const chainCerts = + chainPems + .match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g) + ?.map(pem => new X509Certificate(pem)) ?? []; + const expectedSubjectToken = JSON.stringify( + chainCerts.map(cert => cert.raw.toString('base64')), + ); + + const client = new IdentityPoolClient(certificateSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + }); + + it('should throw when leaf cert is in the middle of trust chain', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config.json'; + const trustChainPath = + './test/fixtures/external-account-cert/chain_with_leaf_middle.pem'; + const certificateSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + trust_chain_path: trustChainPath, + }, + }, + }; + const client = new IdentityPoolClient(certificateSourcedOptions); + + await assert.rejects( + client.retrieveSubjectToken(), + new RegExp( + 'Leaf certificate exists in the trust chain but is not the first entry', + ), + ); + }); + + it('should return subject token when leaf cert is not in trust chain', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config.json'; + const trustChainPath = + './test/fixtures/external-account-cert/chain_with_no_leaf.pem'; + const certificateSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + trust_chain_path: trustChainPath, + }, + }, + }; + const leafCert = new X509Certificate( + fs.readFileSync( + './test/fixtures/external-account-cert/leaf.crt', + 'utf8', + ), + ); + const chainPems = fs.readFileSync(trustChainPath, 'utf8'); + const chainCerts = + chainPems + .match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g) + ?.map(pem => new X509Certificate(pem)) ?? []; + const expectedSubjectToken = JSON.stringify( + [leafCert, ...chainCerts].map(cert => cert.raw.toString('base64')), + ); + + const client = new IdentityPoolClient(certificateSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + }); + + it('should throw when one or more certs in trust chain is malformed', async () => { + const certConfigPath = + './test/fixtures/external-account-cert/cert_config.json'; + const trustChainPath = + './test/fixtures/external-account-cert/chain_with_malformed_cert.pem'; + const certificateSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:mtls', + token_url: getMtlsTokenUrl(), + credential_source: { + certificate: { + certificate_config_location: certConfigPath, + trust_chain_path: trustChainPath, + }, + }, + }; + const client = new IdentityPoolClient(certificateSourcedOptions); + + await assert.rejects( + client.retrieveSubjectToken(), + new RegExp( + `Failed to parse certificate at index 0 in trust chain file ${ + trustChainPath + }`, + ), + ); + }); + }); + }); }); interface TestSubjectTokenSupplierOptions { diff --git a/test/test.pluggableauthclient.ts b/test/test.pluggableauthclient.ts index 9b4abfc1..1a80007e 100644 --- a/test/test.pluggableauthclient.ts +++ b/test/test.pluggableauthclient.ts @@ -37,6 +37,7 @@ import { } from '../src/auth/executable-response'; import {PluggableAuthHandler} from '../src/auth/pluggable-auth-handler'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; +import {TestUtils} from './utils'; const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; @@ -113,7 +114,7 @@ describe('PluggableAuthClient', () => { GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: '1', }); sandbox.stub(process, 'env').value(envVars); - clock = sinon.useFakeTimers({now: referenceTime}); + clock = TestUtils.useFakeTimers(sinon, referenceTime); responseJson = { success: true, diff --git a/test/test.pluggableauthhandler.ts b/test/test.pluggableauthhandler.ts index ad442887..016d4a06 100644 --- a/test/test.pluggableauthhandler.ts +++ b/test/test.pluggableauthhandler.ts @@ -30,6 +30,7 @@ import { } from '../src/auth/pluggable-auth-handler'; import * as assert from 'assert'; import {ExecutableError} from '../src/auth/pluggable-auth-client'; +import {TestUtils} from './utils'; const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token'; const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2'; @@ -121,8 +122,7 @@ describe('PluggableAuthHandler', () => { beforeEach(() => { // Stub environment variables sandbox.stub(process, 'env').value(process.env); - clock = sandbox.useFakeTimers({now: referenceTime}); - + clock = TestUtils.useFakeTimers(sinon, referenceTime); defaultResponseJson = { success: true, version: 1, @@ -371,8 +371,7 @@ describe('PluggableAuthHandler', () => { let defaultResponseJson: ExecutableResponseJson; beforeEach(() => { - clock = sandbox.useFakeTimers({now: referenceTime}); - + clock = TestUtils.useFakeTimers(sinon, referenceTime); defaultResponseJson = { success: true, version: 1, diff --git a/test/test.util.ts b/test/test.util.ts index d167ba5b..b992003a 100644 --- a/test/test.util.ts +++ b/test/test.util.ts @@ -15,7 +15,12 @@ import {strict as assert} from 'assert'; import * as sinon from 'sinon'; -import {LRUCache, removeUndefinedValuesInObject} from '../src/util'; +import { + isValidFile, + LRUCache, + removeUndefinedValuesInObject, +} from '../src/util'; +import {TestUtils} from './utils'; describe('util', () => { let sandbox: sinon.SinonSandbox; @@ -61,7 +66,7 @@ describe('util', () => { it('should evict items older than a supplied `maxAge`', async () => { const maxAge = 50; - sandbox.clock = sinon.useFakeTimers(); + sandbox.clock = TestUtils.useFakeTimers(sandbox); const lru = new LRUCache({capacity: 5, maxAge}); @@ -80,11 +85,23 @@ describe('util', () => { assert.equal(lru.get('second'), undefined); }); }); + + describe('isValidFilePath', () => { + it('should return true when valid file path', async () => { + const isValidPath = await isValidFile('./test/fixtures/empty.json'); + assert.equal(isValidPath, true); + }); + + it('should return false when invalid file path', async () => { + const isValidPath = await isValidFile('abc/pqr'); + assert.equal(isValidPath, false); + }); + }); }); describe('util removeUndefinedValuesInObject', () => { it('remove undefined type values in object', () => { - const object: {[key: string]: any} = { + const object: {[key: string]: unknown} = { undefined: undefined, number: 1, }; @@ -93,7 +110,7 @@ describe('util removeUndefinedValuesInObject', () => { }); }); it('remove undefined string values in object', () => { - const object: {[key: string]: any} = { + const object: {[key: string]: unknown} = { undefined: 'undefined', number: 1, }; diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 00000000..99a02458 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,58 @@ +// Copyright 2025 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 {SinonSandbox, SinonFakeTimers} from 'sinon'; + +type FakeTimersParam = Parameters[0]; +interface FakeTimerConfig { + now?: number | Date; + toFake?: string[]; +} + +/** + * Utilities for unit test code. + * + * @private + */ +export class TestUtils { + /** + * This helper should be used to enable fake timers for Sinon sandbox. + * sinon adds a timer to `nextTick` by default beginning in v19 + * manually specifying the timers like this replicates the behavior pre v19 + * + * @param sandbox The sandbox + * @param now An optional date to set for "now" + * @returns The clock object from useFakeTimers() + */ + static useFakeTimers( + sandbox: SinonSandbox, + now?: number | Date, + ): SinonFakeTimers { + const config: FakeTimerConfig = { + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'Date', + ], + }; + if (now) { + config.now = now; + } + + // The types are screwy in useFakeTimers(). I'm just going to pick one. + return sandbox.useFakeTimers(config as FakeTimersParam); + } +}