From 42631e323ea4823b5ad172ccfacee447244b4706 Mon Sep 17 00:00:00 2001 From: werman Date: Wed, 22 Apr 2026 11:56:37 -0700 Subject: [PATCH 1/3] feat: Regional Access Boundary Migration. (#8043) --- .../src/auth/authclient.ts | 91 +++++- .../src/auth/baseexternalclient.ts | 84 ++++-- .../src/auth/computeclient.ts | 42 +++ .../src/auth/downscopedclient.ts | 16 +- .../externalAccountAuthorizedUserClient.ts | 55 +++- .../src/auth/idtokenclient.ts | 11 +- .../src/auth/impersonated.ts | 25 ++ .../src/auth/jwtclient.ts | 27 +- .../src/auth/oauth2client.ts | 29 +- .../src/auth/regionalaccessboundary.ts | 278 ++++++++++++++++++ .../google-auth-library-nodejs/src/util.ts | 37 +++ .../test/test.authclient.ts | 242 ++++++++++++++- .../test/test.baseexternalclient.ts | 235 +++++++++++++++ .../test/test.compute.ts | 112 ++++++- ...est.externalaccountauthorizeduserclient.ts | 101 ++++++- .../test/test.externalclient.ts | 71 ++--- .../test/test.impersonated.ts | 110 +++++++ .../test/test.jwt.ts | 173 +++++++++++ .../google-auth-library-nodejs/tsconfig.json | 5 +- 19 files changed, 1627 insertions(+), 117 deletions(-) create mode 100644 core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts diff --git a/core/packages/google-auth-library-nodejs/src/auth/authclient.ts b/core/packages/google-auth-library-nodejs/src/auth/authclient.ts index f18dd58e2abf..ea9ff1abdd31 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/authclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/authclient.ts @@ -20,6 +20,11 @@ import {OriginalAndCamel, originalOrCamelOptions} from '../util'; import {log as makeLog} from 'google-logging-utils'; import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs'; +import { + isRegionalAccessBoundaryEnabled, + RegionalAccessBoundaryData, + RegionalAccessBoundaryManager, +} from './regionalaccessboundary'; /** * An interface for enforcing `fetch`-type compliance. @@ -232,6 +237,8 @@ export abstract class AuthClient eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS; forceRefreshOnFailure = false; universeDomain = DEFAULT_UNIVERSE; + regionalAccessBoundaryEnabled: boolean; + protected regionalAccessBoundaryManager: RegionalAccessBoundaryManager; /** * Symbols that can be added to GaxiosOptions to specify the method name that is @@ -254,10 +261,17 @@ export abstract class AuthClient this.quotaProjectId = options.get('quota_project_id'); this.credentials = options.get('credentials') ?? {}; this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE; + this.regionalAccessBoundaryEnabled = isRegionalAccessBoundaryEnabled(); // Shared client options this.transporter = opts.transporter ?? new Gaxios(opts.transporterOptions); + this.regionalAccessBoundaryManager = new RegionalAccessBoundaryManager({ + transporter: this.transporter, + getLookupUrl: async () => this.getRegionalAccessBoundaryUrl(), + isUniverseDomainDefault: () => this.universeDomain === DEFAULT_UNIVERSE, + }); + if (options.get('useAuthRequestParameters') !== false) { this.transporter.interceptors.request.add( AuthClient.DEFAULT_REQUEST_INTERCEPTOR, @@ -361,6 +375,21 @@ export abstract class AuthClient res?: GaxiosResponse | null; }>; + /** + * Returns the regional access boundary lookup URL for the current client. + * This method is intended for internal use by the RegionalAccessBoundaryManager + * and should not be called directly by users. + * + * @return The regional access boundary URL string, or `null` if the client type + * does not support regional access boundaries. + * @throws {Error} If the URL cannot be constructed for a compatible client, + * for instance, if a required property like a service account email is missing. + * @internal + */ + public async getRegionalAccessBoundaryUrl(): Promise { + return null; + } + /** * Sets the auth credentials. */ @@ -368,6 +397,22 @@ export abstract class AuthClient this.credentials = credentials; } + /** + * Returns the current regional access boundary data. + * @internal + */ + getRegionalAccessBoundary(): RegionalAccessBoundaryData | null { + return this.regionalAccessBoundaryManager.data; + } + + /** + * Returns the current regional access boundary cooldown time in milliseconds. + * @internal + */ + getRegionalAccessBoundaryCooldownTime(): number { + return this.regionalAccessBoundaryManager.cooldownTime; + } + /** * Append additional headers, e.g., x-goog-user-project, shared across the * classes inheriting AuthClient. This method should be used by any method @@ -386,23 +431,47 @@ export abstract class AuthClient ) { headers.set('x-goog-user-project', this.quotaProjectId); } + return headers; } /** - * Adds the `x-goog-user-project` and `authorization` headers to the target Headers + * Applies regional access boundary rules to the provided headers. + * This includes adding the x-allowed-locations header and triggering + * a background refresh if needed. + * @param headers The headers to update. + * @param url Optional destination URL of the request. If missing, assumed global. + */ + protected applyRegionalAccessBoundary( + headers: Headers, + url?: string | URL, + ): void { + const rabHeader = + this.regionalAccessBoundaryManager.getRegionalAccessBoundaryHeader( + url, + headers, + ); + if (rabHeader) { + headers.set('x-allowed-locations', rabHeader); + } + } + + /** + * Adds the `x-goog-user-project`, `authorization`, and 'x-allowed-locations' + * headers to the target Headers * object, if they exist on the source. * * @param target the headers to target * @param source the headers to source from * @returns the target headers */ - protected addUserProjectAndAuthHeaders( + protected applyHeadersFromSource( target: T, source: Headers, ): T { const xGoogUserProject = source.get('x-goog-user-project'); const authorizationHeader = source.get('authorization'); + const xGoogAllowedLocs = source.get('x-allowed-locations'); if (xGoogUserProject) { target.set('x-goog-user-project', xGoogUserProject); @@ -412,6 +481,10 @@ export abstract class AuthClient target.set('authorization', authorizationHeader); } + if (xGoogAllowedLocs) { + target.set('x-allowed-locations', xGoogAllowedLocs); + } + return target; } @@ -549,6 +622,20 @@ export abstract class AuthClient }, }; } + + /** + * Returns whether the provided credentials are expired or will expire within + * eagerRefreshThresholdMillismilliseconds. + * If there is no expiry time, assumes the token is not expired or expiring. + * @param credentials The credentials to check for expiration. + * @return Whether the credentials are expired or not. + */ + protected isExpired(credentials: Credentials = this.credentials): boolean { + const now = new Date().getTime(); + return credentials.expiry_date + ? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis + : false; + } } // TypeScript does not have `HeadersInit` in the standard types yet diff --git a/core/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts b/core/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts index 48111eb6345a..f569a590fc29 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts @@ -31,7 +31,16 @@ import { import * as sts from './stscredentials'; import {ClientAuthentication} from './oauth2common'; import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; +import { + getWorkforcePoolIdFromAudience, + getWorkloadPoolIdFromAudience, +} from '../util'; import {pkg} from '../shared.cjs'; +import { + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, + WORKFORCE_LOOKUP_ENDPOINT, + WORKLOAD_LOOKUP_ENDPOINT, +} from './regionalaccessboundary'; /** * The required token exchange grant_type: rfc8693#section-2.1 @@ -415,11 +424,12 @@ export abstract class BaseExternalAccountClient extends AuthClient { * The result has the form: * { authorization: 'Bearer ' } */ - async getRequestHeaders(): Promise { + async getRequestHeaders(url?: string | URL): Promise { const accessTokenResponse = await this.getAccessToken(); const headers = new Headers({ authorization: `Bearer ${accessTokenResponse.token}`, }); + this.applyRegionalAccessBoundary(headers, url); return this.addSharedMetadataHeaders(headers); } @@ -499,13 +509,14 @@ export abstract class BaseExternalAccountClient extends AuthClient { reAuthRetried = false, ): Promise> { let response: GaxiosResponse; + const requestOpts = {...opts}; try { - const requestHeaders = await this.getRequestHeaders(); - opts.headers = Gaxios.mergeHeaders(opts.headers); + const requestHeaders = await this.getRequestHeaders(opts.url); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.addUserProjectAndAuthHeaders(opts.headers, requestHeaders); + this.applyHeadersFromSource(requestOpts.headers, requestHeaders); - response = await this.transporter.request(opts); + response = await this.transporter.request(requestOpts); } catch (e) { const res = (e as GaxiosError).response; if (res) { @@ -684,19 +695,6 @@ export abstract class BaseExternalAccountClient extends AuthClient { }; } - /** - * Returns whether the provided credentials are expired or not. - * If there is no expiry time, assumes the token is not expired or expiring. - * @param accessToken The credentials to check for expiration. - * @return Whether the credentials are expired or not. - */ - private isExpired(accessToken: Credentials): boolean { - const now = new Date().getTime(); - return accessToken.expiry_date - ? now >= accessToken.expiry_date - this.eagerRefreshThresholdMillis - : false; - } - /** * @return The list of scopes for the requested GCP access token. */ @@ -722,4 +720,54 @@ export abstract class BaseExternalAccountClient extends AuthClient { protected getTokenUrl(): string { return this.tokenUrl; } + + /** + * Returns the regional access boundary lookup URL for the external account. + * This implementation constructs the URL based on the audience of the + * workforce or workload pool. If the client is configured for service account + * impersonation, it uses the target service account email to generate + * the lookup endpoint. + * + * @return The regional access boundary URL string. + * @internal + */ + public async getRegionalAccessBoundaryUrl(): Promise { + if (this.serviceAccountImpersonationUrl) { + // When impersonating a service account, the regional access boundary is determined + // by the security policies of the target service account. + const email = this.getServiceAccountEmail(); + if (!email) { + throw new Error( + `RegionalAccessBoundary: A service account email is required for regional access boundary lookups but could not be determined from the serviceAccountImpersonationUrl ${this.serviceAccountImpersonationUrl}.`, + ); + } + return SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(email), + ); + } + + // Check if the audience corresponds to a workload identity pool. + const wfPoolId = getWorkforcePoolIdFromAudience(this.audience); + if (wfPoolId) { + return WORKFORCE_LOOKUP_ENDPOINT.replace( + '{pool_id}', + encodeURIComponent(wfPoolId), + ); + } + + // Check if the audience corresponds to a workforce identity pool. + const wlPoolId = getWorkloadPoolIdFromAudience(this.audience); + const projectNumber = this.getProjectNumber(this.audience); + if (wlPoolId && projectNumber) { + return WORKLOAD_LOOKUP_ENDPOINT.replace( + '{project_id}', + projectNumber, + ).replace('{pool_id}', wlPoolId); + } + + throw new RangeError( + `RegionalAccessBoundary: Invalid audience provided: "${this.audience}" does not correspond to a workforce or workload pool.`, + ); + } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/computeclient.ts b/core/packages/google-auth-library-nodejs/src/auth/computeclient.ts index 36ca73d172aa..a5329e4cfa2b 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/computeclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/computeclient.ts @@ -21,6 +21,7 @@ import { OAuth2Client, OAuth2ClientOptions, } from './oauth2client'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface ComputeOptions extends OAuth2ClientOptions { /** @@ -137,4 +138,45 @@ export class Compute extends OAuth2Client { } } } + + /** + * Returns the regional access boundary lookup URL for the GCE instance. + * This implementation resolves the default service account email of the GCE + * instance to construct the lookup endpoint. + * + * @return The regional access boundary URL string. + * @internal + */ + public async getRegionalAccessBoundaryUrl(): Promise { + const email = await this.resolveServiceAccountEmail(); + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(email), + ); + return regionalAccessBoundaryUrl; + } + + /** + * Resolves the service account email. If the email is set to 'default', + * it fetches the email from the GCE metadata server. + * @returns A promise that resolves with the service account email. + */ + private async resolveServiceAccountEmail(): Promise { + if (this.serviceAccountEmail !== 'default') { + // If a specific email is provided, return it directly. + return this.serviceAccountEmail; + } + + // Otherwise, fetch the default email from the metadata server. + try { + return await gcpMetadata.instance('service-accounts/default/email'); + } catch (e) { + throw new Error( + 'RegionalAccessBoundary: Failed to retrieve default service account email from metadata server.', + { + cause: e, + }, + ); + } + } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/downscopedclient.ts b/core/packages/google-auth-library-nodejs/src/auth/downscopedclient.ts index bc0d19b16b81..cbb6a1e255b2 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/downscopedclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/downscopedclient.ts @@ -290,7 +290,7 @@ export class DownscopedClient extends AuthClient { const requestHeaders = await this.getRequestHeaders(); opts.headers = Gaxios.mergeHeaders(opts.headers); - this.addUserProjectAndAuthHeaders(opts.headers, requestHeaders); + this.applyHeadersFromSource(opts.headers, requestHeaders); response = await this.transporter.request(opts); } catch (e) { @@ -381,18 +381,4 @@ export class DownscopedClient extends AuthClient { // 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; - } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts b/core/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts index 320db05546ad..59d8f42f6f43 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts @@ -33,6 +33,8 @@ import { EXPIRATION_TIME_OFFSET, SharedExternalAccountClientOptions, } from './baseexternalclient'; +import {WORKFORCE_LOOKUP_ENDPOINT} from './regionalaccessboundary'; +import {getWorkforcePoolIdFromAudience} from '../util'; /** * The credentials JSON file type for external account authorized user clients. @@ -159,6 +161,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { private cachedAccessToken: CredentialsWithResponse | null; private readonly externalAccountAuthorizedUserHandler: ExternalAccountAuthorizedUserHandler; private refreshToken: string; + private readonly audience: string; /** * Instantiates an ExternalAccountAuthorizedUserClient instances using the @@ -172,6 +175,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { if (options.universe_domain) { this.universeDomain = options.universe_domain; } + this.audience = options.audience; this.refreshToken = options.refresh_token; const clientAuthentication = { confidentialClientType: 'basic', @@ -218,11 +222,20 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { }; } - async getRequestHeaders(): Promise { + /** + * 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. + * + * @param url The URI being authorized. + * @returns A promise that resolves with authorization header fields. + */ + async getRequestHeaders(url?: string | URL): Promise { const accessTokenResponse = await this.getAccessToken(); const headers = new Headers({ authorization: `Bearer ${accessTokenResponse.token}`, }); + this.applyRegionalAccessBoundary(headers, url); return this.addSharedMetadataHeaders(headers); } @@ -256,13 +269,14 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { reAuthRetried = false, ): Promise> { let response: GaxiosResponse; + const requestOpts = {...opts}; try { - const requestHeaders = await this.getRequestHeaders(); - opts.headers = Gaxios.mergeHeaders(opts.headers); + const requestHeaders = await this.getRequestHeaders(opts.url); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.addUserProjectAndAuthHeaders(opts.headers, requestHeaders); + this.applyHeadersFromSource(requestOpts.headers, requestHeaders); - response = await this.transporter.request(opts); + response = await this.transporter.request(requestOpts); } catch (e) { const res = (e as GaxiosError).response; if (res) { @@ -308,21 +322,34 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { if (refreshResponse.refresh_token !== undefined) { this.refreshToken = refreshResponse.refresh_token; + + // Set credentials. + this.credentials = {...this.cachedAccessToken}; + delete (this.credentials as CredentialsWithResponse).res; } return this.cachedAccessToken; } /** - * Returns whether the provided credentials are expired or not. - * If there is no expiry time, assumes the token is not expired or expiring. - * @param credentials The credentials to check for expiration. - * @return Whether the credentials are expired or not. + * Returns the regional access boundary lookup URL for the external account + * authorized user. + * This implementation constructs the lookup endpoint using the workforce + * pool ID resolved from the audience. + * + * @return The regional access boundary URL string. + * @internal */ - private isExpired(credentials: Credentials): boolean { - const now = new Date().getTime(); - return credentials.expiry_date - ? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis - : false; + public async getRegionalAccessBoundaryUrl(): Promise { + const poolId = getWorkforcePoolIdFromAudience(this.audience); + if (!poolId) { + throw new Error( + `RegionalAccessBoundary: A workforce pool ID is required for regional access boundary lookups but could not be determined from the audience: ${this.audience}.`, + ); + } + return WORKFORCE_LOOKUP_ENDPOINT.replace( + '{pool_id}', + encodeURIComponent(poolId), + ); } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts b/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts index 68303c97e36a..638b6db8a4ec 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts @@ -54,7 +54,7 @@ export class IdTokenClient extends OAuth2Client { if ( !this.credentials.id_token || !this.credentials.expiry_date || - this.isTokenExpiring() + this.isExpired() ) { const idToken = await this.idTokenProvider.fetchIdToken( this.targetAudience, @@ -68,7 +68,12 @@ export class IdTokenClient extends OAuth2Client { const headers = new Headers({ authorization: 'Bearer ' + this.credentials.id_token, }); - return {headers}; + return { + headers, + // Since ID-tokens are outside RAB scope, isIDToken is used as a flag + // to avoid RAB lookup. + isIDToken: true, + }; } private getIdTokenExpiryDate(idToken: string): number | void { @@ -80,4 +85,4 @@ export class IdTokenClient extends OAuth2Client { return payload.exp * 1000; } } -} +} \ No newline at end of file diff --git a/core/packages/google-auth-library-nodejs/src/auth/impersonated.ts b/core/packages/google-auth-library-nodejs/src/auth/impersonated.ts index 97742ef668b3..d146c11f31de 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/impersonated.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/impersonated.ts @@ -24,6 +24,7 @@ import {IdTokenProvider} from './idtokenclient'; import {GaxiosError} from 'gaxios'; import {SignBlobResponse} from './googleauth'; import {originalOrCamelOptions} from '../util'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface ImpersonatedOptions extends OAuth2ClientOptions { /** @@ -202,6 +203,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { const tokenResponse = res.data; this.credentials.access_token = tokenResponse.accessToken; this.credentials.expiry_date = Date.parse(tokenResponse.expireTime); + return { tokens: this.credentials, res, @@ -260,4 +262,27 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { return res.data.token; } + + /** + * Returns the regional access boundary lookup URL for the impersonated + * service account. + * This implementation uses the target principal (service account email) + * to construct the lookup endpoint. + * + * @return The regional access boundary URL string. + * @internal + */ + public async getRegionalAccessBoundaryUrl(): Promise { + const targetPrincipal = this.getTargetPrincipal(); + if (!targetPrincipal) { + throw new Error( + 'RegionalAccessBoundary: A targetPrincipal is required for regional access boundary lookups but was not provided in the ImpersonatedClient options.', + ); + } + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(targetPrincipal), + ); + return regionalAccessBoundaryUrl; + } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/jwtclient.ts b/core/packages/google-auth-library-nodejs/src/auth/jwtclient.ts index 55ce73e849e7..2f5c0bda0ec6 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/jwtclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/jwtclient.ts @@ -26,6 +26,7 @@ import { RequestMetadataResponse, } from './oauth2client'; import {DEFAULT_UNIVERSE} from './authclient'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface JWTOptions extends OAuth2ClientOptions { /** @@ -147,6 +148,9 @@ export class JWT extends OAuth2Client implements IdTokenProvider { authorization: `Bearer ${tokens.id_token}`, }), ), + // Since ID-tokens are outside RAB scope, + // isIDToken is used as a flag to avoid RAB lookup. + isIDToken: true, }; } else { // no scopes have been set, but a uri has been provided. Use JWTAccess @@ -271,7 +275,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { protected async refreshTokenNoCache(): Promise { const gtoken = this.createGToken(); const token = await gtoken.getToken({ - forceRefresh: this.isTokenExpiring(), + forceRefresh: this.isExpired(), }); const tokens = { access_token: token.access_token, @@ -408,4 +412,25 @@ export class JWT extends OAuth2Client implements IdTokenProvider { } throw new Error('A key or a keyFile must be provided to getCredentials.'); } + + /** + * Returns the regional access boundary lookup URL for the service account. + * This implementation uses the configured service account email to construct + * the lookup endpoint. + * + * @return The regional access boundary URL string. + * @internal + */ + public async getRegionalAccessBoundaryUrl(): Promise { + if (!this.email) { + throw new Error( + 'RegionalAccessBoundary: An email address is required for regional access boundary lookups but was not provided in the JwtClient options.', + ); + } + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(this.email), + ); + return regionalAccessBoundaryUrl; + } } diff --git a/core/packages/google-auth-library-nodejs/src/auth/oauth2client.ts b/core/packages/google-auth-library-nodejs/src/auth/oauth2client.ts index 138e66c462b5..4bf698df95e9 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/oauth2client.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/oauth2client.ts @@ -378,6 +378,11 @@ export interface RefreshAccessTokenResponse { export interface RequestMetadataResponse { headers: Headers; res?: GaxiosResponse | null; + /** + * Whether the returned headers contain an ID token (OIDC) instead of an + * access token. ID tokens are out of scope for Regional Access Boundaries. + */ + isIDToken?: boolean; } export interface RequestMetadataCallback { @@ -901,8 +906,7 @@ export class OAuth2Client extends AuthClient { } private async getAccessTokenAsync(): Promise { - const shouldRefresh = - !this.credentials.access_token || this.isTokenExpiring(); + const shouldRefresh = !this.credentials.access_token || this.isExpired(); if (shouldRefresh) { if (!this.credentials.refresh_token) { if (this.refreshHandler) { @@ -938,7 +942,10 @@ export class OAuth2Client extends AuthClient { * { authorization: 'Bearer ' } */ async getRequestHeaders(url?: string | URL): Promise { - const headers = (await this.getRequestMetadataAsync(url)).headers; + const {headers, isIDToken} = await this.getRequestMetadataAsync(url); + if (!isIDToken) { + this.applyRegionalAccessBoundary(headers, url); + } return headers; } @@ -1118,17 +1125,23 @@ export class OAuth2Client extends AuthClient { opts: GaxiosOptions, reAuthRetried = false, ): Promise> { + const requestOpts = {...opts}; try { - const r = await this.getRequestMetadataAsync(); - opts.headers = Gaxios.mergeHeaders(opts.headers); + const {headers, isIDToken} = await this.getRequestMetadataAsync(); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.addUserProjectAndAuthHeaders(opts.headers, r.headers); + this.applyHeadersFromSource(requestOpts.headers, headers); if (this.apiKey) { - opts.headers.set('X-Goog-Api-Key', this.apiKey); + requestOpts.headers.set('X-Goog-Api-Key', this.apiKey); + } + + if (!isIDToken) { + // Id token flows are outside the scope of Regional Access Boundary. + this.applyRegionalAccessBoundary(requestOpts.headers, opts.url); } - return await this.transporter.request(opts); + return await this.transporter.request(requestOpts); } catch (e) { const res = (e as GaxiosError).response; if (res) { diff --git a/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts new file mode 100644 index 000000000000..8a8b591972c9 --- /dev/null +++ b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts @@ -0,0 +1,278 @@ +// Copyright 2026 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 {Gaxios, GaxiosOptions} from 'gaxios'; +import {log as makeLog} from 'google-logging-utils'; + +const log = makeLog('auth'); + +export const SERVICE_ACCOUNT_LOOKUP_ENDPOINT = + 'https://staging-iamcredentials.sandbox.googleapis.com/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; + +export const WORKLOAD_LOOKUP_ENDPOINT = + 'https://staging-iamcredentials.sandbox.googleapis.com/v1/projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations'; + +export const WORKFORCE_LOOKUP_ENDPOINT = + 'https://staging-iamcredentials.sandbox.googleapis.com/v1/locations/global/workforcePools/{pool_id}/allowedLocations'; + +/** + * RAB is considered valid for 6 hours. + */ +const RAB_TTL_MILLIS = 6 * 60 * 60 * 1000; + +/** + * Grace period before hard expiry to trigger a background refresh (1 hour). + */ +const RAB_SOFT_EXPIRY_GRACE_PERIOD_MILLIS = 1 * 60 * 60 * 1000; + +/** + * Initial cooldown period for RAB lookup failures (15 minutes). + */ +const RAB_INITIAL_COOLDOWN_MILLIS = 15 * 60 * 1000; + +/** + * Maximum cooldown period for RAB lookup failures. + * Set as 6 hours. + */ +const RAB_MAX_COOLDOWN_MILLIS = 6 * 60 * 60 * 1000; + +/** + * Holds regional access boundary related information like locations + * where the credentials can be used. + */ +export interface RegionalAccessBoundaryData { + /** + * The readable text format of the allowed regional access boundary locations. + */ + locations?: string[]; + + /** + * The encoded text format of allowed regional access boundary locations. + */ + encodedLocations: string; +} + +export function isRegionalAccessBoundaryEnabled() { + const rabEnabled = + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + if (rabEnabled === undefined || rabEnabled === null) { + return false; + } + const lowercasedRabEnabled = rabEnabled.toLowerCase(); + if (lowercasedRabEnabled === 'true' || rabEnabled === '1') { + return true; + } + return false; +} + +export interface RegionalAccessBoundaryManagerOptions { + transporter: Gaxios; + getLookupUrl: () => Promise; + isUniverseDomainDefault: () => boolean; +} + +export class RegionalAccessBoundaryManager { + private regionalAccessBoundary: RegionalAccessBoundaryData | null = null; + private regionalAccessBoundaryExpiry = 0; + private regionalAccessBoundaryRefreshPromise: Promise | null = null; + private regionalAccessBoundaryCooldownTime = 0; + private regionalAccessBoundaryCooldownBackoff = RAB_INITIAL_COOLDOWN_MILLIS; + private options: RegionalAccessBoundaryManagerOptions; + + constructor(options: RegionalAccessBoundaryManagerOptions) { + this.options = options; + } + + get enabled(): boolean { + return isRegionalAccessBoundaryEnabled(); + } + + /** + * @internal + */ + get data(): RegionalAccessBoundaryData | null { + return this.regionalAccessBoundary; + } + + /** + * @internal + */ + get cooldownTime(): number { + return this.regionalAccessBoundaryCooldownTime; + } + + /** + * Returns the encoded locations string if the RAB is active and valid. + * Also triggers a background refresh if needed. + * @param url Optional endpoint URL being accessed. If missing, assumed global. + * @param headers The headers of the current request. + */ + getRegionalAccessBoundaryHeader( + url: string | URL | undefined, + headers: Headers, + ): string | null { + if (!this.enabled || !this.options.isUniverseDomainDefault()) { + return null; + } + + // Only attach/refresh for global endpoints + if (url && !this.isGlobalEndpoint(url)) { + return null; + } + + // Attempt to trigger refresh if we have a token. + const authHeader = headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + // authHeader.substring(7) as auth header is of type 'Bearer XYZ...' + this.maybeTriggerRegionalAccessBoundaryRefresh(authHeader.substring(7)); + } + + if ( + this.regionalAccessBoundary && + this.regionalAccessBoundary.encodedLocations && + Date.now() < this.regionalAccessBoundaryExpiry + ) { + return this.regionalAccessBoundary.encodedLocations; + } + return null; + } + + /** + * Checks if the given URL is a global endpoint (not regional). + * @param url The URL to check. + */ + private isGlobalEndpoint(url: string | URL): boolean { + try { + const hostname = + url instanceof URL ? url.hostname : new URL(url).hostname; + return ( + !hostname.endsWith('.rep.googleapis.com') && + !hostname.endsWith('.rep.sandbox.googleapis.com') + ); + } catch { + // If the URL is relative or malformed, assume it is global. + return true; + } + } + + /** + * Triggers an asynchronous regional access boundary refresh if needed. + * @param accessToken The access token to use for the lookup. + */ + private maybeTriggerRegionalAccessBoundaryRefresh(accessToken: string): void { + if (this.regionalAccessBoundaryRefreshPromise) { + return; + } + + const now = Date.now(); + + // Check if in cooldown + if (now < this.regionalAccessBoundaryCooldownTime) { + return; + } + + // Check if expired or never fetched (using soft expiry grace period) + const softExpiryThreshold = + this.regionalAccessBoundaryExpiry - RAB_SOFT_EXPIRY_GRACE_PERIOD_MILLIS; + if (!this.regionalAccessBoundary || now >= softExpiryThreshold) { + this.regionalAccessBoundaryRefreshPromise = + this.backgroundRefreshRegionalAccessBoundary(accessToken); + } + } + + /** + * Performs the background refresh of the regional access boundary. + * @param accessToken The access token to use for the lookup. + */ + private async backgroundRefreshRegionalAccessBoundary( + accessToken: string, + ): Promise { + try { + const data = await this.fetchRegionalAccessBoundary(accessToken); + if (data) { + this.regionalAccessBoundary = data; + this.regionalAccessBoundaryExpiry = Date.now() + RAB_TTL_MILLIS; + // Reset cooldown on success. + this.regionalAccessBoundaryCooldownTime = 0; + this.regionalAccessBoundaryCooldownBackoff = + RAB_INITIAL_COOLDOWN_MILLIS; + } + } catch (error) { + // Non-retryable or all retries failed: enter cooldown, + // initially for 15 mins which doubles after each failed cooldown 'exit' attempt. + this.regionalAccessBoundaryCooldownTime = + Date.now() + this.regionalAccessBoundaryCooldownBackoff; + this.regionalAccessBoundaryCooldownBackoff = Math.min( + this.regionalAccessBoundaryCooldownBackoff * 2, + RAB_MAX_COOLDOWN_MILLIS, + ); + log.error( + 'RegionalAccessBoundary: Lookup failed. Entering cooldown.', + error, + ); + } finally { + this.regionalAccessBoundaryRefreshPromise = null; + } + } + + /** + * Internal method to fetch RAB data. + * Retries for retryable 5xx errors from the RAB lookup endpoint. + * Throws if response from lookup is malformed. + */ + private async fetchRegionalAccessBoundary( + accessToken?: string, + ): Promise { + const regionalAccessBoundaryUrl = await this.options.getLookupUrl(); + if (!regionalAccessBoundaryUrl) { + return null; + } + + if (!accessToken) { + throw new Error( + 'RegionalAccessBoundary: Error calling lookup endpoint without valid access token', + ); + } + + const headers = new Headers({ + authorization: 'Bearer ' + accessToken, + }); + + const opts: GaxiosOptions = { + retry: true, + retryConfig: { + retry: 9, // Approximately 1 minute with default exponential backoff + retryDelay: 100, + httpMethodsToRetry: ['GET'], + statusCodesToRetry: [ + [500, 500], + [502, 504], + ], + }, + headers, + url: regionalAccessBoundaryUrl, + }; + + const {data: regionalAccessBoundaryData} = + await this.options.transporter.request(opts); + + if (!regionalAccessBoundaryData.encodedLocations) { + throw new Error( + 'RegionalAccessBoundary: Malformed response from lookup endpoint.', + ); + } + + return regionalAccessBoundaryData; + } +} \ No newline at end of file diff --git a/core/packages/google-auth-library-nodejs/src/util.ts b/core/packages/google-auth-library-nodejs/src/util.ts index 238ab604b109..d965887f5d52 100644 --- a/core/packages/google-auth-library-nodejs/src/util.ts +++ b/core/packages/google-auth-library-nodejs/src/util.ts @@ -300,3 +300,40 @@ export function getWellKnownCertificateConfigFileLocation(): string { function _isWindows(): boolean { return os.platform().startsWith('win'); } + +/** + * Returns the workforce identity pool ID if it is determinable + * from the audience resource name. + * @param audience The audience used to determine the pool ID. + * @return The pool ID associated with the workforce identity pool, if + * this can be determined from the audience field. Otherwise, null is + * returned. + */ +export function getWorkforcePoolIdFromAudience( + audience: string, +): string | null { + // STS audience pattern: + // .../workforcePools/$WORKFORCE_POOL_ID/providers/... + return ( + audience.match(/\/workforcePools\/(?[^/]+)\/providers\//)?.groups + ?.poolId ?? null + ); +} + +/** + * Returns the workload identity pool ID if it is determinable + * from the audience resource name. + * @param audience The audience used to determine the pool ID. + * @return The pool ID associated with the workload identity pool, if + * this can be determined from the audience field. Otherwise, null is + * returned. + */ +export function getWorkloadPoolIdFromAudience(audience: string): string | null { + // STS audience pattern: + // .../workloadIdentityPools/POOL_ID/providers/... + return ( + audience.match( + /\/workloadIdentityPools\/(?[^/]+)\/providers\//, + )?.groups?.workloadPool ?? null + ); +} \ No newline at end of file diff --git a/core/packages/google-auth-library-nodejs/test/test.authclient.ts b/core/packages/google-auth-library-nodejs/test/test.authclient.ts index 22b2528c6497..7c4748f5e790 100644 --- a/core/packages/google-auth-library-nodejs/test/test.authclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.authclient.ts @@ -13,7 +13,6 @@ // limitations under the License. import {strict as assert} from 'assert'; - import * as nock from 'nock'; import { Gaxios, @@ -23,10 +22,16 @@ import { GaxiosResponse, } from 'gaxios'; -import {AuthClient, PassThroughClient} from '../src'; +import {AuthClient, Compute, PassThroughClient} from '../src'; import {snakeToCamel} from '../src/util'; import {PRODUCT_NAME, USER_AGENT} from '../src/shared.cjs'; import * as logging from 'google-logging-utils'; +import {BASE_PATH, HOST_ADDRESS, HEADERS} from 'gcp-metadata'; +import sinon = require('sinon'); +import { + RegionalAccessBoundaryData, + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, +} from '../src/auth/regionalaccessboundary'; // Fakes for the logger, to capture logs that would've happened. interface TestLog { @@ -54,6 +59,17 @@ class TestLogSink implements logging.DebugLogBackend { } describe('AuthClient', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + nock.cleanAll(); + }); + it('should accept and normalize snake case options to camel case', () => { const expected = { project_id: 'my-projectId', @@ -376,5 +392,225 @@ describe('AuthClient', () => { }); }); }); + + describe('regional access boundaries', () => { + const MOCK_ACCESS_TOKEN = 'abc123'; + const SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'; + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['us-central1', 'europe-west1'], + encodedLocations: '0x123', + }; + + function setupTokenNock( + email: string | 'default' = 'default', + ): nock.Scope { + const tokenPath = + email === 'default' + ? `${BASE_PATH}/instance/service-accounts/default/token` + : `${BASE_PATH}/instance/service-accounts/${email}/token`; + return nock(HOST_ADDRESS) + .get(tokenPath) + .reply( + 200, + {access_token: MOCK_ACCESS_TOKEN, expires_in: 10000}, + HEADERS, + ); + } + + beforeEach(() => { + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + }); + + it('should trigger asynchronous background refresh and not block', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + }); + // Set up nocks + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); + // Use a promise to track when the RAB lookup is actually called + let rabLookupCalled = false; + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(SERVICE_ACCOUNT_EMAIL), + ); + + const rabScope = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + // Initial call - should NOT have the header yet because refresh is async + const headers = await compute.getRequestHeaders( + 'https://pubsub.googleapis.com', + ); + + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for the background task to complete (not ideal but necessary for testing side effect) + // In a real scenario we'd use a better way to wait for the internal promise + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + + assert.strictEqual(rabLookupCalled, true); + + // Give the background processing a moment to update the class member + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + compute.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + tokenScope.done(); + rabScope.done(); + }); + + it('should NOT trigger lookup for regional endpoints', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + }); + + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); + // No RAB nock setup here. If it's called, nock will throw. + + await compute.getRequestHeaders('https://us-east1.rep.googleapis.com'); + + tokenScope.done(); + // Assert no RAB lookup was attempted (implicitly verified by lack of nock error) + }); + + it('should NOT trigger lookup for non-GDU universes', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + universe_domain: 'custom-universe.com', + }); + + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + await compute.getRequestHeaders('https://pubsub.googleapis.com'); + + tokenScope.done(); + // Assert no RAB lookup was attempted + }); + + it('should NOT crash and should trigger lookup for relative URLs', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + }); + + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); + // If it treats the relative URL as global, it should try to call the RAB endpoint. + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{universe_domain}', + 'googleapis.com', + ).replace( + '{service_account_email}', + encodeURIComponent(SERVICE_ACCOUNT_EMAIL), + ); + + const rabScope = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(200, EXPECTED_RAB_DATA); + + // This should NOT throw even though '/v1/resource' is relative + await compute.getRequestHeaders('/v1/resource'); + + // Wait for the background task to complete + let attempts = 0; + while (!compute.getRegionalAccessBoundary() && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + + assert.deepStrictEqual( + compute.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + tokenScope.done(); + rabScope.done(); + }); + + it('should retry on retryable errors in background', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + }); + + setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + // Mock 503 then 200 + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(SERVICE_ACCOUNT_EMAIL), + ); + + const rabFail = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(503); + const rabSuccess = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(200, EXPECTED_RAB_DATA); + + await compute.getRequestHeaders('https://pubsub.googleapis.com'); + + // Wait for retries (exponential backoff might take a moment) + let attempts = 0; + while (!compute.getRegionalAccessBoundary() && attempts < 20) { + await new Promise(r => setTimeout(r, 150)); + attempts++; + } + + assert.deepStrictEqual( + compute.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + rabFail.done(); + rabSuccess.done(); + }); + + it('should enter cooldown on non-retryable error', async () => { + const compute = new Compute({ + serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + }); + + setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(SERVICE_ACCOUNT_EMAIL), + ); + + const rabFail = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(400, {error: 'Permanent failure'}); + + await compute.getRequestHeaders('https://pubsub.googleapis.com'); + + // Wait for it to fail and enter cooldown + let attempts = 0; + while ( + !compute.getRegionalAccessBoundaryCooldownTime() && + attempts < 10 + ) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + + assert.ok(compute.getRegionalAccessBoundaryCooldownTime() > Date.now()); + + // Subsequent call should NOT trigger nock (which would fail as we only set up 1) + await compute.getRequestHeaders('https://pubsub.googleapis.com'); + + rabFail.done(); + }); + }); }); -}); +}); \ No newline at end of file diff --git a/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts b/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts index 3020b47cd49e..573a4df0083e 100644 --- a/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts @@ -38,9 +38,16 @@ import { mockGenerateAccessToken, mockStsTokenExchange, getExpectedExternalAccountMetricsHeaderValue, + saEmail, } from './externalclienthelper'; import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; import {TestUtils} from './utils'; +import { + RegionalAccessBoundaryData, + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, + WORKFORCE_LOOKUP_ENDPOINT, + WORKLOAD_LOOKUP_ENDPOINT, +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); interface SampleResponse { @@ -2706,4 +2713,232 @@ describe('BaseExternalAccountClient', () => { ); }); }); + + describe('regional access boundaries', () => { + const MOCK_ACCESS_TOKEN = 'ACCESS_TOKEN'; + const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['some-locations'], + encodedLocations: '0xdeadbeef', + }; + + beforeEach(() => { + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + nock.cleanAll(); + }); + + it('should trigger asynchronous RAB refresh for workload identity', async () => { + const projectNumber = '12345'; + const workloadPoolId = 'my-pool'; + const workloadAudience = `//iam.googleapis.com/projects/${projectNumber}/locations/global/workloadIdentityPools/${workloadPoolId}/providers/my-provider`; + const workloadOptions = { + ...externalAccountOptions, + audience: workloadAudience, + }; + const client = new TestExternalAccountClient(workloadOptions); + + const stsScope = mockStsTokenExchange([ + { + statusCode: 200, + response: {...stsSuccessfulResponse, access_token: MOCK_ACCESS_TOKEN}, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: workloadAudience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const lookupUrl = WORKLOAD_LOOKUP_ENDPOINT.replace( + '{project_id}', + projectNumber, + ).replace('{pool_id}', workloadPoolId); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', MOCK_AUTH_HEADER) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + // Initial call - should NOT have the header yet + const headers = await client.getRequestHeaders(); + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for background lookup + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + client.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + stsScope.done(); + rabScope.done(); + }); + + it('should trigger asynchronous RAB refresh for workforce identity', async () => { + const workforcePoolId = 'my-workforce-pool'; + const location = 'global'; + const workforceAudience = `//iam.googleapis.com/locations/${location}/workforcePools/${workforcePoolId}/providers/my-provider`; + const workforceOptions = { + ...externalAccountOptions, + audience: workforceAudience, + }; + const client = new TestExternalAccountClient(workforceOptions); + + const stsScope = mockStsTokenExchange([ + { + statusCode: 200, + response: {...stsSuccessfulResponse, access_token: MOCK_ACCESS_TOKEN}, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: workforceAudience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( + '{location}', + location, + ).replace('{pool_id}', workforcePoolId); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', MOCK_AUTH_HEADER) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + const headers = await client.getRequestHeaders(); + assert.strictEqual(headers.get('x-allowed-locations'), null); + + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + client.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + stsScope.done(); + rabScope.done(); + }); + + it('should fail background lookup for an invalid audience', async () => { + const invalidAudience = 'invalid-audience-format/providers/1235'; + const invalidOptions = { + ...externalAccountOptions, + audience: invalidAudience, + }; + const client = new TestExternalAccountClient(invalidOptions); + + // Note: background refresh fails silently in terms of getRequestHeaders resolving. + // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. + await assert.rejects( + client.getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: Invalid audience provided/, + ); + }); + + it('should trigger asynchronous RAB refresh for impersonated service account', async () => { + const projectNumber = '12345'; + const workloadPoolId = 'my-pool'; + const workloadAudience = `//iam.googleapis.com/projects/${projectNumber}/locations/global/workloadIdentityPools/${workloadPoolId}/providers/my-provider`; + const workloadOptions = { + ...externalAccountOptionsWithSA, + audience: workloadAudience, + }; + const client = new TestExternalAccountClient(workloadOptions); + + const stsScope = mockStsTokenExchange([ + { + statusCode: 200, + response: {...stsSuccessfulResponse, access_token: MOCK_ACCESS_TOKEN}, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: workloadAudience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(Date.now() + 60 * 60 * 100).toISOString(), + }; + const impersonatedScope = mockGenerateAccessToken({ + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(saEmail), + ); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', `Bearer ${saSuccessResponse.accessToken}`) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + const headers = await client.getRequestHeaders(); + assert.strictEqual(headers.get('x-allowed-locations'), null); + + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + client.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + stsScope.done(); + rabScope.done(); + impersonatedScope.done(); + }); + }); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.compute.ts b/core/packages/google-auth-library-nodejs/test/test.compute.ts index 981eac6c4f69..301a75691c1b 100644 --- a/core/packages/google-auth-library-nodejs/test/test.compute.ts +++ b/core/packages/google-auth-library-nodejs/test/test.compute.ts @@ -17,7 +17,11 @@ import {describe, it, beforeEach, afterEach} from 'mocha'; import {BASE_PATH, HEADERS, HOST_ADDRESS} from 'gcp-metadata'; import * as nock from 'nock'; import * as sinon from 'sinon'; -import {Compute} from '../src'; +import { Compute, gcpMetadata } from '../src'; +import { + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -261,4 +265,110 @@ describe('compute', () => { assert.fail('failed to throw'); }); + describe('regional access boundaries', () => { + let sandbox: sinon.SinonSandbox; + + const MOCK_ACCESS_TOKEN = 'abc123'; + const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['sadad', 'asdad'], + encodedLocations: '000x9', + }; + + function setupTokenNock(email: string | 'default' = 'default'): nock.Scope { + const tokenPath = + email === 'default' + ? `${BASE_PATH}/instance/service-accounts/default/token` + : `${BASE_PATH}/instance/service-accounts/${email}/token`; + return nock(HOST_ADDRESS) + .get(tokenPath) + .reply( + 200, + { access_token: MOCK_ACCESS_TOKEN, expires_in: 10000 }, + HEADERS, + ); + } + + function setupRegionalAccessBoundaryNock( + email: string, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, + ): nock.Scope { + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(email), + ); + return nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', MOCK_AUTH_HEADER) + .reply(200, regionalAccessBoundaryData); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + sandbox.restore(); + nock.cleanAll(); + }); + + it('should trigger asynchronous RAB refresh using email from metadata server', async () => { + const compute = new Compute(); + const fakeEmail = 'fake-default-sa@developer.gserviceaccount.com'; + const metadataStub = sandbox.stub(gcpMetadata, 'instance'); + metadataStub.callThrough(); + metadataStub + .withArgs('service-accounts/default/email') + .resolves(fakeEmail); + + const tokenScope = setupTokenNock('default'); + const rabScope = setupRegionalAccessBoundaryNock(fakeEmail); + let rabLookupCalled = false; + rabScope.on('request', () => { + rabLookupCalled = true; + }); + + const url = 'https://pubsub.googleapis.com'; + const headers = await compute.getRequestHeaders(url); + + // Initial headers should NOT have RAB + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for background tasks (email resolution + RAB lookup) + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 100)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + compute.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + tokenScope.done(); + rabScope.done(); + }); + + it('should fail getRegionalAccessBoundaryUrl in background if metadata call fails', async () => { + const compute = new Compute(); + + const metadataStub = sandbox.stub(gcpMetadata, 'instance'); + metadataStub.callThrough(); + metadataStub + .withArgs('service-accounts/default/email') + .rejects(new Error('metadata failure')); + + // Error happens in background, so getRequestHeaders resolves fine. + // We manually call getRegionalAccessBoundaryUrl to verify the failure logic. + await assert.rejects( + compute.getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: Failed to retrieve default service account email from metadata server./, + ); + }); + }); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts b/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts index e5aca190ebc9..cefe14e0fce6 100644 --- a/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts @@ -17,7 +17,7 @@ import {describe, it, afterEach, beforeEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; import * as qs from 'querystring'; -import {assertGaxiosResponsePresent, getAudience} from './externalclienthelper'; +import {assertGaxiosResponsePresent} from './externalclienthelper'; import { EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, ExternalAccountAuthorizedUserClient, @@ -31,6 +31,10 @@ import { } from '../src/auth/oauth2common'; import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; import {TestUtils} from './utils'; +import { + RegionalAccessBoundaryData, + WORKFORCE_LOOKUP_ENDPOINT, +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -83,10 +87,11 @@ describe('ExternalAccountAuthorizedUserClient', () => { let clock: sinon.SinonFakeTimers; const referenceDate = new Date('2020-08-11T06:55:22.345Z'); - const audience = getAudience(); + const workforcePoolAudience = + '//iam.googleapis.com/locations/global/workforcePools/pool-id-123/providers/provider-id-abc'; const externalAccountAuthorizedUserCredentialOptions = { type: EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, - audience: audience, + audience: workforcePoolAudience, client_id: 'clientId', client_secret: 'clientSecret', refresh_token: 'refreshToken', @@ -95,7 +100,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { } as ExternalAccountAuthorizedUserClientOptions; const externalAccountAuthorizedUserCredentialOptionsNoToken = { type: EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, - audience: audience, + audience: workforcePoolAudience, client_id: 'clientId', client_secret: 'clientSecret', refresh_token: 'refreshToken', @@ -893,4 +898,92 @@ describe('ExternalAccountAuthorizedUserClient', () => { scopes.forEach(scope => scope.done()); }); }); + + describe('regional access boundaries', () => { + const MOCK_ACCESS_TOKEN = 'newAccessToken'; + const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['some-locations'], + encodedLocations: '0xdeadbeef', + }; + + beforeEach(() => { + clock.restore(); + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + nock.cleanAll(); + }); + + it('should trigger asynchronous RAB refresh successfully', async () => { + const workforcePoolId = 'pool-id-123'; + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + ); + + const stsScope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( + '{pool_id}', + encodeURIComponent(workforcePoolId), + ); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', MOCK_AUTH_HEADER) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + // Initial call - should NOT have the header yet + const headers = await client.getRequestHeaders(); + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for background lookup + let attempts = 0; + while (!rabLookupCalled && attempts < 20) { + await new Promise(r => setTimeout(r, 100)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 100)); + assert.deepStrictEqual( + client.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + stsScope.done(); + rabScope.done(); + }); + + it('should fail background lookup for an invalid audience', async () => { + const invalidAudience = 'invalid-audience-format'; + const options = { + ...externalAccountAuthorizedUserCredentialOptions, + audience: invalidAudience, + }; + const client = new ExternalAccountAuthorizedUserClient(options); + + // Note: background refresh fails silently in terms of getRequestHeaders resolving. + // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. + await assert.rejects( + client.getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: A workforce pool ID is required for regional access boundary lookups but could not be determined from the audience/, + ); + }); + }); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.externalclient.ts b/core/packages/google-auth-library-nodejs/test/test.externalclient.ts index d85a420b7fc3..6c64d360150f 100644 --- a/core/packages/google-auth-library-nodejs/test/test.externalclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.externalclient.ts @@ -109,45 +109,35 @@ describe('ExternalAccountClient', () => { ]; it('should return IdentityPoolClient on IdentityPoolClientOptions', () => { - const expectedClient = new IdentityPoolClient(fileSourcedOptions); - - assert.deepStrictEqual( - ExternalAccountClient.fromJSON(fileSourcedOptions), - expectedClient, - ); + const client = ExternalAccountClient.fromJSON(fileSourcedOptions); + assert.ok(client instanceof IdentityPoolClient); }); it('should return IdentityPoolClient with expected RefreshOptions', () => { - const expectedClient = new IdentityPoolClient({ + const client = ExternalAccountClient.fromJSON({ ...fileSourcedOptions, ...refreshOptions, }); - assert.deepStrictEqual( - ExternalAccountClient.fromJSON({ - ...fileSourcedOptions, - ...refreshOptions, - }), - expectedClient, - ); + assert.ok(client instanceof IdentityPoolClient); + assert.strictEqual(client!.eagerRefreshThresholdMillis, 10000); + assert.strictEqual(client!.forceRefreshOnFailure, true); }); it('should return AwsClient on AwsClientOptions', () => { - const expectedClient = new AwsClient(awsOptions); - - assert.deepStrictEqual( - ExternalAccountClient.fromJSON(awsOptions), - expectedClient, - ); + const client = ExternalAccountClient.fromJSON(awsOptions); + assert.ok(client instanceof AwsClient); }); it('should return AwsClient with expected RefreshOptions', () => { - const expectedClient = new AwsClient({...awsOptions, ...refreshOptions}); + const client = ExternalAccountClient.fromJSON({ + ...awsOptions, + ...refreshOptions, + }); - assert.deepStrictEqual( - ExternalAccountClient.fromJSON({...awsOptions, ...refreshOptions}), - expectedClient, - ); + assert.ok(client instanceof AwsClient); + assert.strictEqual(client!.eagerRefreshThresholdMillis, 10000); + assert.strictEqual(client!.forceRefreshOnFailure, true); }); it('should return an IdentityPoolClient with a workforce config', () => { @@ -167,41 +157,28 @@ describe('ExternalAccountClient', () => { for (const validWorkforceIdentityPoolClientAudience of validWorkforceIdentityPoolClientAudiences) { workforceFileSourcedOptions.audience = validWorkforceIdentityPoolClientAudience; - const expectedClient = new IdentityPoolClient( - workforceFileSourcedOptions, - ); - assert.deepStrictEqual( - ExternalAccountClient.fromJSON(workforceFileSourcedOptions), - expectedClient, + const client = ExternalAccountClient.fromJSON( + workforceFileSourcedOptions, ); + assert.ok(client instanceof IdentityPoolClient); } }); it('should return PluggableAuthClient on PluggableAuthClientOptions', () => { - const expectedClient = new PluggableAuthClient( - pluggableAuthClientOptions, - ); - - assert.deepStrictEqual( - ExternalAccountClient.fromJSON(pluggableAuthClientOptions), - expectedClient, - ); + const client = ExternalAccountClient.fromJSON(pluggableAuthClientOptions); + assert.ok(client instanceof PluggableAuthClient); }); it('should return PluggableAuthClient with expected RefreshOptions', () => { - const expectedClient = new PluggableAuthClient({ + const client = ExternalAccountClient.fromJSON({ ...pluggableAuthClientOptions, ...refreshOptions, }); - assert.deepStrictEqual( - ExternalAccountClient.fromJSON({ - ...pluggableAuthClientOptions, - ...refreshOptions, - }), - expectedClient, - ); + assert.ok(client instanceof PluggableAuthClient); + assert.strictEqual(client!.eagerRefreshThresholdMillis, 10000); + assert.strictEqual(client!.forceRefreshOnFailure, true); }); invalidWorkforceIdentityPoolClientAudiences.forEach( diff --git a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts index e7be5ddf177b..b8ea708414d8 100644 --- a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts +++ b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts @@ -19,6 +19,11 @@ import * as nock from 'nock'; import {describe, it, afterEach} from 'mocha'; import {Impersonated, JWT, UserRefreshClient} from '../src'; import {CredentialRequest} from '../src/auth/credentials'; +import { + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; +import sinon = require('sinon'); const PEM_PATH = './test/fixtures/private.pem'; @@ -589,4 +594,109 @@ describe('impersonated', () => { assert.equal(resp.signedBlob, expectedSignedBlob); scopes.forEach(s => s.done()); }); + + describe('regional access boundaries', () => { + let sandbox: sinon.SinonSandbox; + const TARGET_PRINCIPAL_EMAIL = 'target@project.iam.gserviceaccount.com'; + const MOCK_ACCESS_TOKEN = 'abc123'; + const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; + + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['sadad', 'asdad'], + encodedLocations: '000x9', + }; + + function setupRegionalAccessBoundaryNock( + email: string, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, + ): nock.Scope { + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(email), + ); + return nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', MOCK_AUTH_HEADER) + .reply(200, regionalAccessBoundaryData); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + sandbox.restore(); + nock.cleanAll(); + }); + + it('should trigger asynchronous RAB refresh', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const impersonated = new Impersonated({ + sourceClient: createSampleJWTClient(), + targetPrincipal: TARGET_PRINCIPAL_EMAIL, + lifetime: 30, + delegates: [], + targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const tokenScope = createGTokenMock({access_token: MOCK_ACCESS_TOKEN}); + const saScope = nock('https://iamcredentials.googleapis.com') + .post( + `/v1/projects/-/serviceAccounts/${TARGET_PRINCIPAL_EMAIL}:generateAccessToken`, + ) + .reply(200, { + accessToken: MOCK_ACCESS_TOKEN, + expireTime: tomorrow.toISOString(), + }); + + let rabLookupCalled = false; + const rabScope = setupRegionalAccessBoundaryNock(TARGET_PRINCIPAL_EMAIL); + rabScope.on('request', () => { + rabLookupCalled = true; + }); + + const url = 'https://pubsub.googleapis.com'; + const headers = await impersonated.getRequestHeaders(url); + + // Initial headers should NOT have RAB + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for background lookup + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + impersonated.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + tokenScope.done(); + saScope.done(); + rabScope.done(); + }); + + it('should fail getRegionalAccessBoundaryUrl in background if no target principal is specified', async () => { + const impersonated = new Impersonated({ + sourceClient: createSampleJWTClient(), + // targetPrincipal missing + lifetime: 30, + delegates: [], + targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + // Error happens in background. + await assert.rejects( + impersonated.getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: A targetPrincipal is required for regional access boundary lookups but was not provided in the ImpersonatedClient options./, + ); + }); + }); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.jwt.ts b/core/packages/google-auth-library-nodejs/test/test.jwt.ts index cb5ed85ba4ce..338de68c44f5 100644 --- a/core/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/core/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -22,6 +22,10 @@ import * as sinon from 'sinon'; import {GoogleAuth, JWT} from '../src'; import {CredentialRequest, JWTInput} from '../src/auth/credentials'; import * as jwtaccess from '../src/auth/jwtaccess'; +import { + SERVICE_ACCOUNT_LOOKUP_ENDPOINT, + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; function removeBearerFromAuthorizationHeader(headers: Headers): string { return (headers.get('authorization') || '').replace('Bearer ', ''); @@ -1244,4 +1248,173 @@ describe('jwt', () => { assert.strictEqual(headers.get('authorization'), want); }); }); + + describe('regional access boundaries', () => { + let sandbox: sinon.SinonSandbox; + const SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'; + const MOCK_ACCESS_TOKEN = 'abc123'; + const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; + + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['sadad', 'asdad'], + encodedLocations: '000x9', + }; + + function setupRegionalAccessBoundaryNock( + email: string, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, + authHeader = MOCK_AUTH_HEADER, + ): nock.Scope { + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(email), + ); + return nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .matchHeader('authorization', authHeader) + .reply(200, regionalAccessBoundaryData); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + }); + + afterEach(() => { + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + sandbox.restore(); + nock.cleanAll(); + }); + + it('should trigger asynchronous regional access boundaries refresh', async () => { + const jwt = new JWT({ + email: SERVICE_ACCOUNT_EMAIL, + keyFile: PEM_PATH, + scopes: ['http://bar', 'http://foo'], + subject: 'bar@subjectaccount.com', + }); + jwt.credentials = { refresh_token: 'jwt-placeholder' }; + + const tokenScope = createGTokenMock({ access_token: MOCK_ACCESS_TOKEN }); + + let rabLookupCalled = false; + const rabScope = setupRegionalAccessBoundaryNock(SERVICE_ACCOUNT_EMAIL); + rabScope.on('request', () => { + rabLookupCalled = true; + }); + + // Initial call - headers should NOT have the RAB yet + const headers = await jwt.getRequestHeaders( + 'https://pubsub.googleapis.com', + ); + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Wait for background lookup + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + // Give it a moment to update state + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + jwt.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + tokenScope.done(); + rabScope.done(); + }); + + it('should trigger RAB refresh for self-signed JWT', async () => { + // Self-signed JWT (no scopes) + const keys = keypair(512); + const jwt = new JWT({ + email: SERVICE_ACCOUNT_EMAIL, + key: keys.private, + }); + jwt.credentials = { refresh_token: 'jwt-placeholder' }; + + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{service_account_email}', + encodeURIComponent(SERVICE_ACCOUNT_EMAIL), + ); + + let rabLookupCalled = false; + // For self-signed JWT, the lookup uses the JWT itself as the token + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); + + const url = 'https://pubsub.googleapis.com'; + const headers = await jwt.getRequestHeaders(url); + + // Verify headers contain the self-signed JWT + const authHeader = headers.get('authorization'); + assert.ok(authHeader?.startsWith('Bearer ')); + + // Wait for background lookup + let attempts = 0; + while (!rabLookupCalled && attempts < 10) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } + assert.strictEqual(rabLookupCalled, true); + + await new Promise(r => setTimeout(r, 50)); + assert.deepStrictEqual( + jwt.getRegionalAccessBoundary(), + EXPECTED_RAB_DATA, + ); + + rabScope.done(); + }); + + it('should NOT add RAB headers for ID tokens', async () => { + const jwt = new JWT({ + email: SERVICE_ACCOUNT_EMAIL, + key: PEM_CONTENTS, + additionalClaims: { target_audience: 'some-audience' }, + }); + + // Setup a RAB lookup mock that should NOT be hit + const rabScope = setupRegionalAccessBoundaryNock(SERVICE_ACCOUNT_EMAIL); + + const scope = createGTokenMock({ id_token: 'id-token-abc' }); + const headers = await jwt.getRequestHeaders( + 'https://pubsub.googleapis.com', + ); + + assert.strictEqual(headers.get('authorization'), 'Bearer id-token-abc'); + // Should NOT have the RAB header because it's an ID token + assert.strictEqual(headers.get('x-allowed-locations'), null); + + // Ensure RAB lookup was NOT called + assert.strictEqual(rabScope.isDone(), false); + + scope.done(); + }); + + it('should fail getRegionalAccessBoundaryUrl if no email is passed', async () => { + const jwt = new JWT({ + keyFile: PEM_PATH, + scopes: ['http://bar', 'http://foo'], + subject: 'bar@subjectaccount.com', + }); + // Ensure email is explicitly undefined + jwt.email = undefined; + + // Note: error happens in background during getRequestHeaders, + // but we can manually call getRegionalAccessBoundaryUrl to verify it throws. + await assert.rejects( + jwt.getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: An email address is required for regional access boundary lookups but was not provided in the JwtClient options./, + ); + }); + }); }); diff --git a/core/packages/google-auth-library-nodejs/tsconfig.json b/core/packages/google-auth-library-nodejs/tsconfig.json index 572f3da71667..b9f7f940bae4 100644 --- a/core/packages/google-auth-library-nodejs/tsconfig.json +++ b/core/packages/google-auth-library-nodejs/tsconfig.json @@ -1,7 +1,10 @@ { "extends": "./node_modules/gts/tsconfig-google.json", "compilerOptions": { - "lib": ["DOM"], + "lib": [ + "es2023", + "DOM" + ], "composite": true, "rootDir": ".", "outDir": "build", From 3d445616ef34ed152c3c3cc8e6ce5997032d2ad2 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Wed, 22 Apr 2026 14:58:45 -0700 Subject: [PATCH 2/3] RAB endpoints changed from staging to prod; Removed RAB env variable gate; updated tests. --- .../src/auth/authclient.ts | 3 --- .../src/auth/idtokenclient.ts | 2 +- .../src/auth/regionalaccessboundary.ts | 25 ++++--------------- .../google-auth-library-nodejs/src/util.ts | 2 +- .../test/test.authclient.ts | 10 +------- .../test/test.baseexternalclient.ts | 15 +++++++++-- .../test/test.compute.ts | 12 ++++++--- ...est.externalaccountauthorizeduserclient.ts | 2 -- .../test/test.impersonated.ts | 12 +++++++-- .../test/test.jwt.ts | 16 ++++++------ 10 files changed, 48 insertions(+), 51 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/authclient.ts b/core/packages/google-auth-library-nodejs/src/auth/authclient.ts index ea9ff1abdd31..216e3390fdc4 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/authclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/authclient.ts @@ -21,7 +21,6 @@ import {log as makeLog} from 'google-logging-utils'; import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs'; import { - isRegionalAccessBoundaryEnabled, RegionalAccessBoundaryData, RegionalAccessBoundaryManager, } from './regionalaccessboundary'; @@ -237,7 +236,6 @@ export abstract class AuthClient eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS; forceRefreshOnFailure = false; universeDomain = DEFAULT_UNIVERSE; - regionalAccessBoundaryEnabled: boolean; protected regionalAccessBoundaryManager: RegionalAccessBoundaryManager; /** @@ -261,7 +259,6 @@ export abstract class AuthClient this.quotaProjectId = options.get('quota_project_id'); this.credentials = options.get('credentials') ?? {}; this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE; - this.regionalAccessBoundaryEnabled = isRegionalAccessBoundaryEnabled(); // Shared client options this.transporter = opts.transporter ?? new Gaxios(opts.transporterOptions); diff --git a/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts b/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts index 638b6db8a4ec..58ed71ae210a 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/idtokenclient.ts @@ -85,4 +85,4 @@ export class IdTokenClient extends OAuth2Client { return payload.exp * 1000; } } -} \ No newline at end of file +} diff --git a/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts index 8a8b591972c9..7d2dc532038e 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts @@ -18,13 +18,13 @@ import {log as makeLog} from 'google-logging-utils'; const log = makeLog('auth'); export const SERVICE_ACCOUNT_LOOKUP_ENDPOINT = - 'https://staging-iamcredentials.sandbox.googleapis.com/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; export const WORKLOAD_LOOKUP_ENDPOINT = - 'https://staging-iamcredentials.sandbox.googleapis.com/v1/projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations'; + 'https://iamcredentials.googleapis.com/v1/projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations'; export const WORKFORCE_LOOKUP_ENDPOINT = - 'https://staging-iamcredentials.sandbox.googleapis.com/v1/locations/global/workforcePools/{pool_id}/allowedLocations'; + 'https://iamcredentials.googleapis.com/v1/locations/global/workforcePools/{pool_id}/allowedLocations'; /** * RAB is considered valid for 6 hours. @@ -63,18 +63,6 @@ export interface RegionalAccessBoundaryData { encodedLocations: string; } -export function isRegionalAccessBoundaryEnabled() { - const rabEnabled = - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; - if (rabEnabled === undefined || rabEnabled === null) { - return false; - } - const lowercasedRabEnabled = rabEnabled.toLowerCase(); - if (lowercasedRabEnabled === 'true' || rabEnabled === '1') { - return true; - } - return false; -} export interface RegionalAccessBoundaryManagerOptions { transporter: Gaxios; @@ -94,9 +82,6 @@ export class RegionalAccessBoundaryManager { this.options = options; } - get enabled(): boolean { - return isRegionalAccessBoundaryEnabled(); - } /** * @internal @@ -122,7 +107,7 @@ export class RegionalAccessBoundaryManager { url: string | URL | undefined, headers: Headers, ): string | null { - if (!this.enabled || !this.options.isUniverseDomainDefault()) { + if (!this.options.isUniverseDomainDefault()) { return null; } @@ -275,4 +260,4 @@ export class RegionalAccessBoundaryManager { return regionalAccessBoundaryData; } -} \ No newline at end of file +} diff --git a/core/packages/google-auth-library-nodejs/src/util.ts b/core/packages/google-auth-library-nodejs/src/util.ts index d965887f5d52..0d9081c4c2dc 100644 --- a/core/packages/google-auth-library-nodejs/src/util.ts +++ b/core/packages/google-auth-library-nodejs/src/util.ts @@ -336,4 +336,4 @@ export function getWorkloadPoolIdFromAudience(audience: string): string | null { /\/workloadIdentityPools\/(?[^/]+)\/providers\//, )?.groups?.workloadPool ?? null ); -} \ No newline at end of file +} diff --git a/core/packages/google-auth-library-nodejs/test/test.authclient.ts b/core/packages/google-auth-library-nodejs/test/test.authclient.ts index 7c4748f5e790..892fafe3b0ef 100644 --- a/core/packages/google-auth-library-nodejs/test/test.authclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.authclient.ts @@ -417,14 +417,6 @@ describe('AuthClient', () => { ); } - beforeEach(() => { - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; - }); - - afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; - }); - it('should trigger asynchronous background refresh and not block', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, @@ -613,4 +605,4 @@ describe('AuthClient', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts b/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts index 573a4df0083e..112da7c6016d 100644 --- a/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts @@ -162,11 +162,20 @@ describe('BaseExternalAccountClient', () => { '//iam.googleapis.com/projects_suffix/123456', ]; + let sandbox: sinon.SinonSandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox + .stub(BaseExternalAccountClient.prototype, 'getRegionalAccessBoundaryUrl') + .resolves(undefined); + }); + afterEach(() => { nock.cleanAll(); if (clock) { clock.restore(); } + sandbox.restore(); }); describe('Constructor', () => { @@ -2723,11 +2732,13 @@ describe('BaseExternalAccountClient', () => { }; beforeEach(() => { - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + ( + BaseExternalAccountClient.prototype + .getRegionalAccessBoundaryUrl as sinon.SinonStub + ).restore(); }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.compute.ts b/core/packages/google-auth-library-nodejs/test/test.compute.ts index 301a75691c1b..7d248ff6c8df 100644 --- a/core/packages/google-auth-library-nodejs/test/test.compute.ts +++ b/core/packages/google-auth-library-nodejs/test/test.compute.ts @@ -17,7 +17,7 @@ import {describe, it, beforeEach, afterEach} from 'mocha'; import {BASE_PATH, HEADERS, HOST_ADDRESS} from 'gcp-metadata'; import * as nock from 'nock'; import * as sinon from 'sinon'; -import { Compute, gcpMetadata } from '../src'; +import {Compute, gcpMetadata} from '../src'; import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, RegionalAccessBoundaryData, @@ -48,6 +48,9 @@ describe('compute', () => { let compute: Compute; beforeEach(() => { compute = new Compute(); + sandbox + .stub(Compute.prototype, 'getRegionalAccessBoundaryUrl') + .resolves(undefined); }); afterEach(() => { @@ -284,7 +287,7 @@ describe('compute', () => { .get(tokenPath) .reply( 200, - { access_token: MOCK_ACCESS_TOKEN, expires_in: 10000 }, + {access_token: MOCK_ACCESS_TOKEN, expires_in: 10000}, HEADERS, ); } @@ -305,11 +308,12 @@ describe('compute', () => { beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + ( + Compute.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub + ).restore(); }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts b/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts index cefe14e0fce6..ee46da87740f 100644 --- a/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts +++ b/core/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts @@ -909,11 +909,9 @@ describe('ExternalAccountAuthorizedUserClient', () => { beforeEach(() => { clock.restore(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts index b8ea708414d8..cc4a18452ed7 100644 --- a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts +++ b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts @@ -74,8 +74,15 @@ interface ImpersonatedCredentialRequest { } describe('impersonated', () => { + beforeEach(() => { + sinon + .stub(Impersonated.prototype, 'getRegionalAccessBoundaryUrl') + .resolves(undefined); + }); + afterEach(() => { nock.cleanAll(); + sinon.restore(); }); it('should request impersonated credentials on first request', async () => { @@ -622,11 +629,12 @@ describe('impersonated', () => { beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + ( + Impersonated.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub + ).restore(); }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.jwt.ts b/core/packages/google-auth-library-nodejs/test/test.jwt.ts index 338de68c44f5..d93e2aba20dd 100644 --- a/core/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/core/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -72,6 +72,9 @@ describe('jwt', () => { json = createJSON(); jwt = new JWT(); sandbox = sinon.createSandbox(); + sandbox + .stub(JWT.prototype, 'getRegionalAccessBoundaryUrl') + .resolves(undefined); }); afterEach(() => { @@ -1277,11 +1280,10 @@ describe('jwt', () => { beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + (JWT.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub).restore(); }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); @@ -1293,9 +1295,9 @@ describe('jwt', () => { scopes: ['http://bar', 'http://foo'], subject: 'bar@subjectaccount.com', }); - jwt.credentials = { refresh_token: 'jwt-placeholder' }; + jwt.credentials = {refresh_token: 'jwt-placeholder'}; - const tokenScope = createGTokenMock({ access_token: MOCK_ACCESS_TOKEN }); + const tokenScope = createGTokenMock({access_token: MOCK_ACCESS_TOKEN}); let rabLookupCalled = false; const rabScope = setupRegionalAccessBoundaryNock(SERVICE_ACCOUNT_EMAIL); @@ -1335,7 +1337,7 @@ describe('jwt', () => { email: SERVICE_ACCOUNT_EMAIL, key: keys.private, }); - jwt.credentials = { refresh_token: 'jwt-placeholder' }; + jwt.credentials = {refresh_token: 'jwt-placeholder'}; const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{service_account_email}', @@ -1379,13 +1381,13 @@ describe('jwt', () => { const jwt = new JWT({ email: SERVICE_ACCOUNT_EMAIL, key: PEM_CONTENTS, - additionalClaims: { target_audience: 'some-audience' }, + additionalClaims: {target_audience: 'some-audience'}, }); // Setup a RAB lookup mock that should NOT be hit const rabScope = setupRegionalAccessBoundaryNock(SERVICE_ACCOUNT_EMAIL); - const scope = createGTokenMock({ id_token: 'id-token-abc' }); + const scope = createGTokenMock({id_token: 'id-token-abc'}); const headers = await jwt.getRequestHeaders( 'https://pubsub.googleapis.com', ); From 72dd1fae8cace9ba1d0fbd8bac67a24b8dbf130b Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Wed, 22 Apr 2026 15:20:18 -0700 Subject: [PATCH 3/3] removed sinon.createSandbox from nested beforeEach blocks in test.compute.ts, test.jwt.ts, and test.impersonated.ts. --- .../src/auth/regionalaccessboundary.ts | 2 -- core/packages/google-auth-library-nodejs/test/test.compute.ts | 4 ---- .../google-auth-library-nodejs/test/test.impersonated.ts | 3 --- core/packages/google-auth-library-nodejs/test/test.jwt.ts | 3 --- 4 files changed, 12 deletions(-) diff --git a/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts index 7d2dc532038e..b2b5c598e095 100644 --- a/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts +++ b/core/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts @@ -63,7 +63,6 @@ export interface RegionalAccessBoundaryData { encodedLocations: string; } - export interface RegionalAccessBoundaryManagerOptions { transporter: Gaxios; getLookupUrl: () => Promise; @@ -82,7 +81,6 @@ export class RegionalAccessBoundaryManager { this.options = options; } - /** * @internal */ diff --git a/core/packages/google-auth-library-nodejs/test/test.compute.ts b/core/packages/google-auth-library-nodejs/test/test.compute.ts index 7d248ff6c8df..52c6ae85cf4d 100644 --- a/core/packages/google-auth-library-nodejs/test/test.compute.ts +++ b/core/packages/google-auth-library-nodejs/test/test.compute.ts @@ -269,8 +269,6 @@ describe('compute', () => { assert.fail('failed to throw'); }); describe('regional access boundaries', () => { - let sandbox: sinon.SinonSandbox; - const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { @@ -307,14 +305,12 @@ describe('compute', () => { } beforeEach(() => { - sandbox = sinon.createSandbox(); ( Compute.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub ).restore(); }); afterEach(() => { - sandbox.restore(); nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts index cc4a18452ed7..e97d77c74a32 100644 --- a/core/packages/google-auth-library-nodejs/test/test.impersonated.ts +++ b/core/packages/google-auth-library-nodejs/test/test.impersonated.ts @@ -603,7 +603,6 @@ describe('impersonated', () => { }); describe('regional access boundaries', () => { - let sandbox: sinon.SinonSandbox; const TARGET_PRINCIPAL_EMAIL = 'target@project.iam.gserviceaccount.com'; const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; @@ -628,14 +627,12 @@ describe('impersonated', () => { } beforeEach(() => { - sandbox = sinon.createSandbox(); ( Impersonated.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub ).restore(); }); afterEach(() => { - sandbox.restore(); nock.cleanAll(); }); diff --git a/core/packages/google-auth-library-nodejs/test/test.jwt.ts b/core/packages/google-auth-library-nodejs/test/test.jwt.ts index d93e2aba20dd..d9c4f93cc3b9 100644 --- a/core/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/core/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -1253,7 +1253,6 @@ describe('jwt', () => { }); describe('regional access boundaries', () => { - let sandbox: sinon.SinonSandbox; const SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'; const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; @@ -1279,12 +1278,10 @@ describe('jwt', () => { } beforeEach(() => { - sandbox = sinon.createSandbox(); (JWT.prototype.getRegionalAccessBoundaryUrl as sinon.SinonStub).restore(); }); afterEach(() => { - sandbox.restore(); nock.cleanAll(); });