From 7d283f526cafc2a3ecad5edf2a9d06d67d248e0e Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Wed, 14 Jan 2026 15:38:48 -0800 Subject: [PATCH 1/3] Changes for trust boundaries async refresh and support for self-signed JWT --- .../src/auth/authclient.ts | 270 ++++++++++--- .../src/auth/baseexternalclient.ts | 16 +- .../src/auth/computeclient.ts | 11 +- .../externalAccountAuthorizedUserClient.ts | 20 +- .../src/auth/impersonated.ts | 12 +- .../src/auth/jwtclient.ts | 13 +- .../src/auth/oauth2client.ts | 7 + ...tboundary.ts => regionalaccessboundary.ts} | 33 +- .../test/test.authclient.ts | 382 ++++++++---------- .../test/test.baseexternalclient.ts | 171 ++++++-- .../test/test.compute.ts | 72 ++-- ...est.externalaccountauthorizeduserclient.ts | 94 ++++- .../test/test.identitypoolclient.ts | 197 ++++++++- .../test/test.impersonated.ts | 165 +++++--- .../test/test.jwt.ts | 112 ++++- .../test/test.oauth2.ts | 56 +++ 16 files changed, 1108 insertions(+), 523 deletions(-) rename packages/google-auth-library-nodejs/src/auth/{trustboundary.ts => regionalaccessboundary.ts} (58%) diff --git a/packages/google-auth-library-nodejs/src/auth/authclient.ts b/packages/google-auth-library-nodejs/src/auth/authclient.ts index 16905e4fb..6d90b546a 100644 --- a/packages/google-auth-library-nodejs/src/auth/authclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/authclient.ts @@ -21,10 +21,9 @@ import {log as makeLog} from 'google-logging-utils'; import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs'; import { - isTrustBoundaryEnabled, - NoOpEncodedLocations, - TrustBoundaryData, -} from './trustboundary'; + isRegionalAccessBoundaryEnabled, + RegionalAccessBoundaryData, +} from './regionalaccessboundary'; /** * An interface for enforcing `fetch`-type compliance. @@ -160,6 +159,21 @@ export const DEFAULT_UNIVERSE = 'googleapis.com'; */ export const DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS = 5 * 60 * 1000; +/** + * RAB is considered valid for 6 hours. + */ +const RAB_TTL_MILLIS = 6 * 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. + */ +const RAB_MAX_COOLDOWN_MILLIS = 24 * 60 * 60 * 1000; + /** * Defines the root interface for all clients that generate credentials * for calling Google APIs. All clients should implement this interface. @@ -237,8 +251,12 @@ export abstract class AuthClient eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS; forceRefreshOnFailure = false; universeDomain = DEFAULT_UNIVERSE; - trustBoundaryEnabled: boolean; - trustBoundary?: TrustBoundaryData | null; + regionalAccessBoundaryEnabled: boolean; + protected regionalAccessBoundary?: RegionalAccessBoundaryData | null; + private regionalAccessBoundaryExpiry = 0; + private regionalAccessBoundaryRefreshPromise: Promise | null = null; + private regionalAccessBoundaryCooldownTime = 0; + private regionalAccessBoundaryCooldownBackoff = RAB_INITIAL_COOLDOWN_MILLIS; /** * Symbols that can be added to GaxiosOptions to specify the method name that is @@ -261,8 +279,8 @@ 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.trustBoundaryEnabled = isTrustBoundaryEnabled(); - this.trustBoundary = null; + this.regionalAccessBoundaryEnabled = isRegionalAccessBoundaryEnabled(); + this.regionalAccessBoundary = null; // Shared client options this.transporter = opts.transporter ?? new Gaxios(opts.transporterOptions); @@ -371,14 +389,14 @@ export abstract class AuthClient }>; /** - * Constructs the trust boundary lookup URL for the client. + * Constructs the regional access boundary lookup URL for the client. * - * @return The trust boundary URL string, or `null` if the client type - * does not support trust boundaries. + * @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. */ - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { return null; } @@ -389,6 +407,16 @@ export abstract class AuthClient this.credentials = credentials; } + /** + * Manually sets the regional access boundary data. + * Treating this as a standard cache entry with a 6-hour TTL. + * @param data The regional access boundary data to set. + */ + setRegionalAccessBoundary(data: RegionalAccessBoundaryData) { + this.regionalAccessBoundary = data; + this.regionalAccessBoundaryExpiry = Date.now() + RAB_TTL_MILLIS; + } + /** * Append additional headers, e.g., x-goog-user-project, shared across the * classes inheriting AuthClient. This method should be used by any method @@ -408,13 +436,14 @@ export abstract class AuthClient headers.set('x-goog-user-project', this.quotaProjectId); } - if (this.trustBoundaryEnabled && this.trustBoundary) { - //Empty header sent in case trust-boundary has no-op encoded location. + if ( + this.regionalAccessBoundaryEnabled && + this.regionalAccessBoundary && + this.regionalAccessBoundary.encodedLocations + ) { headers.set( 'x-allowed-locations', - this.trustBoundary.encodedLocations === NoOpEncodedLocations - ? '' - : this.trustBoundary.encodedLocations, + this.regionalAccessBoundary.encodedLocations, ); } @@ -589,48 +618,134 @@ export abstract class AuthClient } /** - * Refreshes trust boundary data for an authenticated client. - * Handles caching checks and potential fallbacks. - * @param tokens The refreshed credentials containing access token to call the trust boundary endpoint. - * @returns A Promise resolving to TrustBoundaryData or empty-string for no-op trust boundaries. - * @throws {Error} If the request fails and there is no cache available. + * Checks if the given URL is a global endpoint (not regional). + * @param url The URL to check. */ - protected async refreshTrustBoundary( - tokens: Credentials, - ): Promise { - if (!this.trustBoundaryEnabled) { - return null; + private isGlobalEndpoint(url?: string | URL): boolean { + if (!url) { + return true; } + const hostname = url instanceof URL ? url.hostname : new URL(url).hostname; + return ( + !hostname.endsWith('.rep.googleapis.com') && + !hostname.endsWith('.rep.sandbox.googleapis.com') + ); + } - if (this.universeDomain !== DEFAULT_UNIVERSE) { - // Skipping check for non-default universe domain as this feature is only supported in GDU - return null; + /** + * Triggers an asynchronous regional access boundary refresh if needed. + * @param url The endpoint URL being accessed. + */ + protected maybeTriggerRegionalAccessBoundaryRefresh(url?: string | URL) { + if ( + !this.regionalAccessBoundaryEnabled || + this.universeDomain !== DEFAULT_UNIVERSE || + !this.isGlobalEndpoint(url) || + this.regionalAccessBoundaryRefreshPromise + ) { + return; + } + + const now = Date.now(); + + // Check if in cooldown + if (now < this.regionalAccessBoundaryCooldownTime) { + return; } - const cachedTB = this.trustBoundary; - if (cachedTB && cachedTB.encodedLocations === NoOpEncodedLocations) { - return cachedTB; + // Check if expired or never fetched + if ( + !this.regionalAccessBoundary || + now >= this.regionalAccessBoundaryExpiry + ) { + this.regionalAccessBoundaryRefreshPromise = + this.backgroundRefreshRegionalAccessBoundary(); } + } - const trustBoundaryUrl = await this.getTrustBoundaryUrl(); - if (!trustBoundaryUrl) { - return null; + /** + * Performs the background refresh of the regional access boundary. + */ + private async backgroundRefreshRegionalAccessBoundary(): Promise { + try { + // Get tokens without triggering a recursive RAB lookup if possible. + // Most clients will have cached tokens or refresh them. + const tokens = await this.getAccessToken(); + + // Implement retry with exponential backoff for up to 1 minute. + let attempt = 0; + const startTime = Date.now(); + const maxRetryTime = 60 * 1000; + + while (true) { + try { + const data = await this.fetchRegionalAccessBoundary(tokens); + 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; + } + break; + } catch (error) { + const status = + (error as any).status || (error as any).response?.status; + const isRetryable = status >= 500 || status === 403 || status === 404; + + if (isRetryable && Date.now() - startTime < maxRetryTime) { + attempt++; + const delay = Math.min(Math.pow(2, attempt) * 100, 10000); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + // Non-retryable or timeout: enter cooldown + this.regionalAccessBoundaryCooldownTime = + Date.now() + this.regionalAccessBoundaryCooldownBackoff; + this.regionalAccessBoundaryCooldownBackoff = Math.min( + this.regionalAccessBoundaryCooldownBackoff * 2, + RAB_MAX_COOLDOWN_MILLIS, + ); + AuthClient.log.error( + 'RegionalAccessBoundary: Lookup failed. Entering cooldown.', + error, + ); + break; + } + } + } catch (error) { + AuthClient.log.error( + 'RegionalAccessBoundary: Background refresh failed:', + error, + ); + } finally { + this.regionalAccessBoundaryRefreshPromise = null; } + } - const accessToken = tokens.access_token; + /** + * Internal method to fetch RAB data. + */ + private async fetchRegionalAccessBoundary( + tokens: any, + ): Promise { + const regionalAccessBoundaryUrl = await this.getRegionalAccessBoundaryUrl(); + if (!regionalAccessBoundaryUrl) { + return null; + } - if (!accessToken || this.isExpired(tokens)) { + const accessToken = tokens.token || tokens.access_token; + if (!accessToken) { throw new Error( - 'TrustBoundary: Error calling lookup endpoint without valid access token', + 'RegionalAccessBoundary: Error calling lookup endpoint without valid access token', ); } - const headers = this.addSharedMetadataHeaders( - new Headers({ - //we can directly pass the access_token as the trust boundaries are always fetched after token refresh - authorization: 'Bearer ' + accessToken, - }), - ); + const headers = new Headers({ + authorization: 'Bearer ' + accessToken, + }); const opts: GaxiosOptions = { ...{ @@ -640,33 +755,33 @@ export abstract class AuthClient }, }, headers, - url: trustBoundaryUrl, + url: regionalAccessBoundaryUrl, }; - try { - const {data: trustBoundaryData} = - // Use the transporter directly here. A standard `client.request` would - // re-trigger a token refresh, creating an infinite loop. - await this.transporter.request(opts); - - if (!trustBoundaryData.encodedLocations) { - throw new Error( - 'TrustBoundary: Malformed response from lookup endpoint.', - ); - } + const {data: regionalAccessBoundaryData} = + await this.transporter.request(opts); - return trustBoundaryData; - } catch (error) { - if (this.trustBoundary) { - return this.trustBoundary; // return cached tb if call to lookup fails - } + if (!regionalAccessBoundaryData.encodedLocations) { throw new Error( - 'TrustBoundary: Failure while getting trust boundaries:', - { - cause: error, - }, + 'RegionalAccessBoundary: Malformed response from lookup endpoint.', ); } + + return regionalAccessBoundaryData; + } + + /** + * Refreshes regional access boundary data for an authenticated client. + * @deprecated Use maybeTriggerRegionalAccessBoundaryRefresh instead. + * @param tokens The refreshed credentials containing access token to call the regional access boundary endpoint. + * @returns A Promise resolving to RegionalAccessBoundaryData. + */ + protected async refreshRegionalAccessBoundary( + tokens: Credentials, + ): Promise { + // This is now handled asynchronously in backgroundRefreshRegionalAccessBoundary. + // Keeping it for backward compatibility but it just calls the internal fetch. + return this.fetchRegionalAccessBoundary(tokens); } /** @@ -682,6 +797,29 @@ export abstract class AuthClient ? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis : false; } + + /** + * Checks if the error is a "stale regional access boundary" error. + * @param error The error to check. + */ + protected isStaleRegionalAccessBoundaryError(error: any): boolean { + const res = error.response; + if (res && res.status === 400) { + const data = res.data; + const message = + data?.error?.message || data?.message || error.message || ''; + return message.toLowerCase().includes('stale regional access boundary'); + } + return false; + } + + /** + * Clears the regional access boundary cache. + */ + protected clearRegionalAccessBoundaryCache() { + this.regionalAccessBoundary = null; + this.regionalAccessBoundaryExpiry = 0; + } } // TypeScript does not have `HeadersInit` in the standard types yet diff --git a/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts b/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts index 41a19b323..23c21cc4f 100644 --- a/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts @@ -40,7 +40,7 @@ import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, WORKFORCE_LOOKUP_ENDPOINT, WORKLOAD_LOOKUP_ENDPOINT, -} from './trustboundary'; +} from './regionalaccessboundary'; /** * The required token exchange grant_type: rfc8693#section-2.1 @@ -76,7 +76,7 @@ export const CLOUD_RESOURCE_MANAGER = 'https://cloudresourcemanager.googleapis.com/v1/projects/'; /** The workforce audience pattern. */ const WORKFORCE_AUDIENCE_PATTERN = - '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; + '//iam.googleapis.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token'; /** @@ -429,6 +429,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { const headers = new Headers({ authorization: `Bearer ${accessTokenResponse.token}`, }); + this.maybeTriggerRegionalAccessBoundaryRefresh(); return this.addSharedMetadataHeaders(headers); } @@ -515,6 +516,10 @@ export abstract class BaseExternalAccountClient extends AuthClient { response = await this.transporter.request(opts); } catch (e) { + if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + this.clearRegionalAccessBoundaryCache(); + return await this.requestAsync(opts, true); + } const res = (e as GaxiosError).response; if (res) { const statusCode = res.status; @@ -624,7 +629,6 @@ export abstract class BaseExternalAccountClient extends AuthClient { Object.assign(this.credentials, this.cachedAccessToken); delete (this.credentials as CredentialsWithResponse).res; - this.trustBoundary = await this.refreshTrustBoundary(this.credentials); // Trigger tokens event to notify external listeners. this.emit('tokens', { refresh_token: null, @@ -718,14 +722,14 @@ export abstract class BaseExternalAccountClient extends AuthClient { return this.tokenUrl; } - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { if (this.serviceAccountImpersonationUrl) { // When impersonating a service account, the trust boundary is determined // by the security policies of the target service account. const email = this.getServiceAccountEmail(); if (!email) { throw new Error( - `TrustBoundary: A service account email is required for trust boundary lookups but could not be determined from the serviceAccountImpersonationUrl ${this.serviceAccountImpersonationUrl}.`, + `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( @@ -753,7 +757,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { } throw new RangeError( - `TrustBoundary: Invalid audience provided: "${this.audience}" does not correspond to a workforce or workload pool.`, + `RegionalAccessBoundary: Invalid audience provided: "${this.audience}" does not correspond to a workforce or workload pool.`, ); } } diff --git a/packages/google-auth-library-nodejs/src/auth/computeclient.ts b/packages/google-auth-library-nodejs/src/auth/computeclient.ts index c5393baec..a1a3dc015 100644 --- a/packages/google-auth-library-nodejs/src/auth/computeclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/computeclient.ts @@ -21,7 +21,7 @@ import { OAuth2Client, OAuth2ClientOptions, } from './oauth2client'; -import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './trustboundary'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface ComputeOptions extends OAuth2ClientOptions { /** @@ -90,7 +90,6 @@ export class Compute extends OAuth2Client { tokens.expiry_date = new Date().getTime() + data.expires_in * 1000; delete (tokens as CredentialRequest).expires_in; } - this.trustBoundary = await this.refreshTrustBoundary(data); this.emit('tokens', tokens); return {tokens, res: null}; } @@ -140,13 +139,13 @@ export class Compute extends OAuth2Client { } } - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { const email = await this.resolveServiceAccountEmail(); - const trustBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', this.universeDomain, ).replace('{service_account_email}', encodeURIComponent(email)); - return trustBoundaryUrl; + return regionalAccessBoundaryUrl; } /** @@ -165,7 +164,7 @@ export class Compute extends OAuth2Client { return await gcpMetadata.instance('service-accounts/default/email'); } catch (e) { throw new Error( - 'TrustBoundary: Failed to retrieve default service account email from metadata server.', + 'RegionalAccessBoundary: Failed to retrieve default service account email from metadata server.', { cause: e, }, diff --git a/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts b/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts index e63fd1e6c..015eb4ddb 100644 --- a/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts +++ b/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts @@ -33,7 +33,7 @@ import { EXPIRATION_TIME_OFFSET, SharedExternalAccountClientOptions, } from './baseexternalclient'; -import {WORKFORCE_LOOKUP_ENDPOINT} from './trustboundary'; +import {WORKFORCE_LOOKUP_ENDPOINT} from './regionalaccessboundary'; import {getWorkforcePoolIdFromAudience} from '../util'; /** @@ -226,6 +226,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { const headers = new Headers({ authorization: `Bearer ${accessTokenResponse.token}`, }); + this.maybeTriggerRegionalAccessBoundaryRefresh(); return this.addSharedMetadataHeaders(headers); } @@ -267,6 +268,10 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { response = await this.transporter.request(opts); } catch (e) { + if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + this.clearRegionalAccessBoundaryCache(); + return await this.requestAsync(opts, true); + } const res = (e as GaxiosError).response; if (res) { const statusCode = res.status; @@ -312,27 +317,26 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { if (refreshResponse.refresh_token !== undefined) { this.refreshToken = refreshResponse.refresh_token; - // Set credentials and refresh trust boundary data. + // Set credentials. this.credentials = {...this.cachedAccessToken}; delete (this.credentials as CredentialsWithResponse).res; - this.trustBoundary = await this.refreshTrustBoundary(this.credentials); } return this.cachedAccessToken; } /** - * Constructs the trust boundary lookup URL for the client. + * Constructs the regional access boundary lookup URL for the client. * - * @return The trust boundary URL string, or `null` if the client type - * does not support trust boundaries. + * @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. */ - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { const poolId = getWorkforcePoolIdFromAudience(this.audience); if (!poolId) { throw new Error( - `TrustBoundary: A workforce pool ID is required for trust boundary lookups but could not be determined from the audience: ${this.audience}.`, + `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( diff --git a/packages/google-auth-library-nodejs/src/auth/impersonated.ts b/packages/google-auth-library-nodejs/src/auth/impersonated.ts index 895aa714c..372c775e8 100644 --- a/packages/google-auth-library-nodejs/src/auth/impersonated.ts +++ b/packages/google-auth-library-nodejs/src/auth/impersonated.ts @@ -24,7 +24,7 @@ import {IdTokenProvider} from './idtokenclient'; import {GaxiosError} from 'gaxios'; import {SignBlobResponse} from './googleauth'; import {originalOrCamelOptions} from '../util'; -import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './trustboundary'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface ImpersonatedOptions extends OAuth2ClientOptions { /** @@ -203,7 +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); - this.trustBoundary = await this.refreshTrustBoundary(this.credentials); + return { tokens: this.credentials, res, @@ -263,17 +263,17 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { return res.data.token; } - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { const targetPrincipal = this.getTargetPrincipal(); if (!targetPrincipal) { throw new Error( - 'TrustBoundary: A targetPrincipal is required for trust boundary lookups but was not provided in the ImpersonatedClient options.', + 'RegionalAccessBoundary: A targetPrincipal is required for regional access boundary lookups but was not provided in the ImpersonatedClient options.', ); } - const trustBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', this.universeDomain, ).replace('{service_account_email}', encodeURIComponent(targetPrincipal)); - return trustBoundaryUrl; + return regionalAccessBoundaryUrl; } } diff --git a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts index 166936662..f5972ff65 100644 --- a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts @@ -25,7 +25,7 @@ import { RequestMetadataResponse, } from './oauth2client'; import {DEFAULT_UNIVERSE} from './authclient'; -import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './trustboundary'; +import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary'; export interface JWTOptions extends OAuth2ClientOptions { /** @@ -141,6 +141,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { ).target_audience ) { const {tokens} = await this.refreshToken(); + this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); return { headers: this.addSharedMetadataHeaders( new Headers({ @@ -180,6 +181,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { useScopes ? scopes : undefined, ); + this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); return {headers: this.addSharedMetadataHeaders(headers)}; } } else if (this.hasAnyScopes() || this.apiKey) { @@ -279,7 +281,6 @@ export class JWT extends OAuth2Client implements IdTokenProvider { expiry_date: gtoken.expiresAt, id_token: gtoken.idToken, }; - this.trustBoundary = await this.refreshTrustBoundary(tokens); this.emit('tokens', tokens); return {res: null, tokens}; } @@ -410,16 +411,16 @@ export class JWT extends OAuth2Client implements IdTokenProvider { throw new Error('A key or a keyFile must be provided to getCredentials.'); } - protected async getTrustBoundaryUrl(): Promise { + protected async getRegionalAccessBoundaryUrl(): Promise { if (!this.email) { throw new Error( - 'TrustBoundary: An email address is required for trust boundary lookups but was not provided in the JwtClient options.', + 'RegionalAccessBoundary: An email address is required for regional access boundary lookups but was not provided in the JwtClient options.', ); } - const trustBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', this.universeDomain, ).replace('{service_account_email}', encodeURIComponent(this.email)); - return trustBoundaryUrl; + return regionalAccessBoundaryUrl; } } diff --git a/packages/google-auth-library-nodejs/src/auth/oauth2client.ts b/packages/google-auth-library-nodejs/src/auth/oauth2client.ts index 6f3e0121b..54cfe2b7e 100644 --- a/packages/google-auth-library-nodejs/src/auth/oauth2client.ts +++ b/packages/google-auth-library-nodejs/src/auth/oauth2client.ts @@ -957,6 +957,8 @@ export class OAuth2Client extends AuthClient { ); } + this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); + if (thisCreds.access_token && !this.isTokenExpiring()) { thisCreds.token_type = thisCreds.token_type || 'Bearer'; const headers = new Headers({ @@ -1129,6 +1131,11 @@ export class OAuth2Client extends AuthClient { return await this.transporter.request(opts); } catch (e) { + if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + this.clearRegionalAccessBoundaryCache(); + // Background refresh is triggered by getRequestMetadataAsync in the retry + return await this.requestAsync(opts, true); + } const res = (e as GaxiosError).response; if (res) { const statusCode = res.status; diff --git a/packages/google-auth-library-nodejs/src/auth/trustboundary.ts b/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts similarity index 58% rename from packages/google-auth-library-nodejs/src/auth/trustboundary.ts rename to packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts index 46793d90f..3af71e4ba 100644 --- a/packages/google-auth-library-nodejs/src/auth/trustboundary.ts +++ b/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts @@ -12,11 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -/** - * value indicating no trust boundaries enforced - **/ -export const NoOpEncodedLocations = '0x0'; - // googleapis.com export const SERVICE_ACCOUNT_LOOKUP_ENDPOINT = 'https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; @@ -28,36 +23,32 @@ export const WORKFORCE_LOOKUP_ENDPOINT = 'https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations'; /** - * Holds trust boundary related information like locations + * Holds regional access boundary related information like locations * where the credentials can be used. */ -export interface TrustBoundaryData { +export interface RegionalAccessBoundaryData { /** - * The readable text format of the allowed trust boundary locations. - * This is optional, as it might not be present if no trust boundary is enforced. + * The readable text format of the allowed regional access boundary locations. + * This is optional, as it might not be present if no regional access boundary is enforced. */ locations?: string[]; /** - * The encoded text format of allowed trust boundary locations. + * The encoded text format of allowed regional access boundary locations. * Expected to always be present in valid responses. */ encodedLocations: string; } -export function isTrustBoundaryEnabled() { - const tbEnabled = process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; - if (tbEnabled === undefined || tbEnabled === null) { +export function isRegionalAccessBoundaryEnabled() { + const rabEnabled = + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + if (rabEnabled === undefined || rabEnabled === null) { return false; } - const lowercasedTbEnabled = tbEnabled.toLowerCase(); - if (lowercasedTbEnabled === 'true' || tbEnabled === '1') { + const lowercasedRabEnabled = rabEnabled.toLowerCase(); + if (lowercasedRabEnabled === 'true' || rabEnabled === '1') { return true; } - if (lowercasedTbEnabled === 'false' || tbEnabled === '0') { - return false; - } - throw new Error( - `Invalid value for GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED environment variable: "${tbEnabled}". Supported values are 'true', '1', 'false', or '0'.`, - ); + return false; } diff --git a/packages/google-auth-library-nodejs/test/test.authclient.ts b/packages/google-auth-library-nodejs/test/test.authclient.ts index 392e2032d..aab4767ad 100644 --- a/packages/google-auth-library-nodejs/test/test.authclient.ts +++ b/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, @@ -30,10 +29,9 @@ import * as logging from 'google-logging-utils'; import {BASE_PATH, HOST_ADDRESS, HEADERS} from 'gcp-metadata'; import sinon = require('sinon'); import { - TrustBoundaryData, + RegionalAccessBoundaryData, SERVICE_ACCOUNT_LOOKUP_ENDPOINT, - NoOpEncodedLocations, -} from '../src/auth/trustboundary'; +} from '../src/auth/regionalaccessboundary'; // Fakes for the logger, to capture logs that would've happened. interface TestLog { @@ -60,6 +58,17 @@ class TestLogSink extends logging.DebugLogBackendBase { } 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', @@ -383,23 +392,13 @@ describe('AuthClient', () => { }); }); - describe('trust boundaries', () => { - const url = 'http://example.com'; - - function mockExample() { - return nock(url).get('/').reply(200); - } - - let sandbox: sinon.SinonSandbox; + describe('regional access boundaries', () => { const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; const SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'; - const EXPECTED_TB_DATA: TrustBoundaryData = { - locations: ['sadad', 'asdad'], - encodedLocations: '000x9', - }; - const NO_OP_TB_DATA: TrustBoundaryData = { - encodedLocations: '0x0', + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { + locations: ['us-central1', 'europe-west1'], + encodedLocations: '0x123', }; function setupTokenNock( @@ -418,25 +417,10 @@ describe('AuthClient', () => { ); } - function setupExpiredTokenNock( - 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: -1}, - HEADERS, - ); - } - - function setupTrustBoundaryNock( + function setupRegionalAccessBoundaryNock( email: string, - trustBoundaryData: TrustBoundaryData = EXPECTED_TB_DATA, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, + statusCode = 200, ): nock.Scope { const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', @@ -445,160 +429,108 @@ describe('AuthClient', () => { return nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, trustBoundaryData); + .reply(statusCode, regionalAccessBoundaryData); } beforeEach(() => { - sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; - sandbox.restore(); - nock.cleanAll(); + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; }); - it('should not call look-up endpoint if GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED is not true', async () => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + it('should trigger asynchronous background refresh and not block', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, }); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - mockExample(), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL), - ]; - await compute.request({url}); - assert.deepStrictEqual(compute.trustBoundary, null); - assert.strictEqual( - scopes[2].isDone(), - false, - 'Trust boundary endpoint should not be called', - ); - scopes[0].done(); - scopes[1].done(); - }); - it('should fetch and return trust boundary data successfully', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - }); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL), - mockExample(), - ]; - - await compute.request({url}); + // Set up nocks + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); - assert.deepStrictEqual(compute.trustBoundary, EXPECTED_TB_DATA); - scopes.forEach(s => s.done()); - }); - - it('should retry trust boundary lookup on failure', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - }); - const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + // Use a promise to track when the RAB lookup is actually called + let rabLookupCalled = false; + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', ).replace( '{service_account_email}', encodeURIComponent(SERVICE_ACCOUNT_EMAIL), ); - const tbScopeFail = nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(503, {error: 'server unavailable'}); - const tbScopeSuccess = nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, EXPECTED_TB_DATA); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - tbScopeFail, - tbScopeSuccess, - mockExample(), - ]; - - await compute.request({url}); - - // The request should have succeeded after the retry. - assert.deepStrictEqual(compute.trustBoundary, EXPECTED_TB_DATA); - scopes.forEach(s => s.done()); - }); - it('refreshTrustBoundary should return null when default domain is not googleapis.com', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - universe_domain: 'abc.com', - }); - const scopes = [setupTokenNock(SERVICE_ACCOUNT_EMAIL), mockExample()]; + const rabScope = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(() => { + rabLookupCalled = true; + return [200, EXPECTED_RAB_DATA]; + }); - await compute.request({url}); + // Initial call - should NOT have the header yet because refresh is async + const headers = await compute.getRequestHeaders( + 'https://pubsub.googleapis.com', + ); - assert.deepStrictEqual(compute.trustBoundary, null); - scopes.forEach(s => s.done()); - }); + assert.strictEqual(headers.get('x-allowed-locations'), null); + // assert.strictEqual(compute.regionalAccessBoundary, null); - it('refreshTrustBoundary should throw when no valid access token is passed', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - }); - const scopes = [setupExpiredTokenNock(SERVICE_ACCOUNT_EMAIL)]; + // 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++; + } - await assert.rejects( - compute.request({url}), - new RegExp( - 'TrustBoundary: Error calling lookup endpoint without valid access token', - ), + 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 as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - scopes.forEach(s => s.done()); + + tokenScope.done(); + rabScope.done(); }); - it('refreshTrustBoundary should return no-op and not call lookup endpoint in case cachedTrustBoundaries is no-op', async () => { + it('should NOT trigger lookup for regional endpoints', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, }); - compute.trustBoundary = {encodedLocations: NoOpEncodedLocations}; - const scopes = [setupTokenNock(SERVICE_ACCOUNT_EMAIL), mockExample()]; - const tbScope = setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL); - await compute.request({url}); - assert.deepStrictEqual( - compute.trustBoundary.encodedLocations, - NoOpEncodedLocations, - ); - scopes.forEach(s => s.done()); - assert.strictEqual(tbScope.isDone(), false); + 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('refreshTrustBoundary should return no-op if response from lookup is no-op', async () => { + it('should NOT trigger lookup for non-GDU universes', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, + universe_domain: 'custom-universe.com', }); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL, NO_OP_TB_DATA), - mockExample(), - ]; - await compute.request({url}); - assert.deepStrictEqual( - compute?.trustBoundary?.encodedLocations, - NoOpEncodedLocations, - ); - scopes.forEach(s => s.done()); + const tokenScope = setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + await compute.getRequestHeaders('https://pubsub.googleapis.com'); + + tokenScope.done(); + // Assert no RAB lookup was attempted }); - it('refreshTrustBoundary should return cached TB if call to lookup fails', async () => { + it('should retry on retryable errors in background', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, }); - compute.trustBoundary = EXPECTED_TB_DATA; - const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + // Mock 503 then 200 + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', ).replace( @@ -606,28 +538,38 @@ describe('AuthClient', () => { encodeURIComponent(SERVICE_ACCOUNT_EMAIL), ); - const tbErrorScope = nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .replyWithError('Something wrong!'); + 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'); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - tbErrorScope, - mockExample(), - ]; + // Wait for retries (exponential backoff might take a moment) + let attempts = 0; + while (!(compute as any).regionalAccessBoundary && attempts < 20) { + await new Promise(r => setTimeout(r, 150)); + attempts++; + } - await compute.request({url}); - assert.deepStrictEqual(compute.trustBoundary, EXPECTED_TB_DATA); - scopes.forEach(s => s.done()); + assert.deepStrictEqual( + (compute as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, + ); + rabFail.done(); + rabSuccess.done(); }); - it('refreshTrustBoundary should throw if call to lookup fails and no cached-TB', async () => { + it('should enter cooldown on non-retryable error', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, }); - const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + const rabUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', ).replace( @@ -635,84 +577,80 @@ describe('AuthClient', () => { encodeURIComponent(SERVICE_ACCOUNT_EMAIL), ); - const tbErrorScope = nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .replyWithError('Something wrong!'); + const rabFail = nock(new URL(rabUrl).origin) + .get(new URL(rabUrl).pathname) + .reply(400, {error: 'Permanent failure'}); - const scopes = [setupTokenNock(SERVICE_ACCOUNT_EMAIL), tbErrorScope]; + await compute.getRequestHeaders('https://pubsub.googleapis.com'); - await assert.rejects( - compute.request({url}), - new RegExp('TrustBoundary: Failure while getting trust boundaries:'), - ); - scopes.forEach(s => s.done()); - }); + // Wait for it to fail and enter cooldown + let attempts = 0; + while ( + !(compute as any).regionalAccessBoundaryCooldownTime && + attempts < 10 + ) { + await new Promise(r => setTimeout(r, 50)); + attempts++; + } - it('refreshTrustBoundary should throw in case of malformed response from lookup', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - }); - const malformedTBData: TrustBoundaryData = { - locations: ['sadad', 'asdad'], - encodedLocations: '', - }; - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL, malformedTBData), - ]; - - await assert.rejects( - compute.request({url}), - new RegExp('TrustBoundary: Failure while getting trust boundaries:'), + assert.ok( + (compute as any).regionalAccessBoundaryCooldownTime > Date.now(), ); - scopes.forEach(s => s.done()); - }); - it('getRequestHeaders should attach a trust boundary header in case of valid tb', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - }); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL), - ]; - - const reqheaders = await compute.getRequestHeaders(); + // Subsequent call should NOT trigger nock (which would fail as we only set up 1) + await compute.getRequestHeaders('https://pubsub.googleapis.com'); - assert.deepStrictEqual( - reqheaders.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, - ); - scopes.forEach(s => s.done()); + rabFail.done(); }); - it('getRequestHeaders should attach an empty string TB header in case of no_op tb', async () => { + it('should support manual override via setRegionalAccessBoundary', async () => { const compute = new Compute({ serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, }); - const scopes = [ - setupTokenNock(SERVICE_ACCOUNT_EMAIL), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL, NO_OP_TB_DATA), - ]; - const reqheaders = await compute.getRequestHeaders(); + setupTokenNock(SERVICE_ACCOUNT_EMAIL); + + compute.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + + const headers = await compute.getRequestHeaders( + 'https://pubsub.googleapis.com', + ); - assert.deepStrictEqual(reqheaders.get('x-allowed-locations'), ''); - scopes.forEach(s => s.done()); + assert.strictEqual( + headers.get('x-allowed-locations'), + EXPECTED_RAB_DATA.encodedLocations, + ); + // No nock setup, so if a network call happened, this would have failed. }); - it('getRequestHeaders should not attach TB header in case of non GDU universe', async () => { - const compute = new Compute({ - serviceAccountEmail: SERVICE_ACCOUNT_EMAIL, - universe_domain: 'abc.com', - }); - const scopes = [setupTokenNock(SERVICE_ACCOUNT_EMAIL)]; + it('should correctly detect stale regional access boundary error', () => { + const compute = new Compute(); + const error = { + response: { + status: 400, + data: { + error: { + message: 'This is a stale regional access boundary error', + }, + }, + }, + }; - const reqheaders = await compute.getRequestHeaders(); + assert.strictEqual( + (compute as any).isStaleRegionalAccessBoundaryError(error), + true, + ); - assert.deepStrictEqual(reqheaders.get('x-allowed-locations'), null); - scopes.forEach(s => s.done()); + const otherError = { + response: { + status: 400, + data: {message: 'Something else'}, + }, + }; + assert.strictEqual( + (compute as any).isStaleRegionalAccessBoundaryError(otherError), + false, + ); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts b/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts index 4c33f4039..af73168f4 100644 --- a/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts +++ b/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts @@ -43,11 +43,11 @@ import { import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; import {TestUtils} from './utils'; import { + RegionalAccessBoundaryData, SERVICE_ACCOUNT_LOOKUP_ENDPOINT, - TrustBoundaryData, WORKFORCE_LOOKUP_ENDPOINT, WORKLOAD_LOOKUP_ENDPOINT, -} from '../src/auth/trustboundary'; +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); interface SampleResponse { @@ -2609,24 +2609,24 @@ describe('BaseExternalAccountClient', () => { }); }); - describe('trust boundaries', () => { + describe('regional access boundaries', () => { const MOCK_ACCESS_TOKEN = 'ACCESS_TOKEN'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; - const EXPECTED_TB_DATA: TrustBoundaryData = { + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['some-locations'], encodedLocations: '0xdeadbeef', }; beforeEach(() => { - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; nock.cleanAll(); }); - it('should fetch trust boundaries successfully for workload identity', async () => { + 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`; @@ -2656,28 +2656,44 @@ describe('BaseExternalAccountClient', () => { '{universe_domain}', 'googleapis.com', ) - .replace('{project_id}', projectNumber) - .replace('{pool_id}', workloadPoolId); - const tbScope = nock(new URL(lookupUrl).origin) + .replace('{project_number}', projectNumber) + .replace('{workload_identity_pool_id}', workloadPoolId); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, EXPECTED_TB_DATA); + .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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (client as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - assert.deepStrictEqual(client.trustBoundary, EXPECTED_TB_DATA); stsScope.done(); - tbScope.done(); + rabScope.done(); }); - it('should fetch trust boundaries successfully for workforce identity', async () => { + it('should trigger asynchronous RAB refresh for workforce identity', async () => { const workforcePoolId = 'my-workforce-pool'; - const workforceAudience = `//iam.googleapis.com/locations/global/workforcePools/${workforcePoolId}/providers/my-provider`; + const location = 'global'; + const workforceAudience = `//iam.googleapis.com/locations/${location}/workforcePools/${workforcePoolId}/providers/my-provider`; const workforceOptions = { ...externalAccountOptions, audience: workforceAudience, @@ -2703,25 +2719,40 @@ describe('BaseExternalAccountClient', () => { const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', - ).replace('{pool_id}', workforcePoolId); - const tbScope = nock(new URL(lookupUrl).origin) + ) + .replace('{location}', location) + .replace('{workforce_pool_id}', workforcePoolId); + + let rabLookupCalled = false; + const rabScope = nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, EXPECTED_TB_DATA); + .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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (client as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - assert.deepStrictEqual(client.trustBoundary, EXPECTED_TB_DATA); stsScope.done(); - tbScope.done(); + rabScope.done(); }); - it('should throw an trust boundary error for an invalid audience', async () => { + it('should fail background lookup for an invalid audience', async () => { const invalidAudience = 'invalid-audience-format/providers/1235'; const invalidOptions = { ...externalAccountOptions, @@ -2745,15 +2776,17 @@ describe('BaseExternalAccountClient', () => { }, ]); + // Note: background refresh fails silently in terms of getRequestHeaders resolving. + // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - client.getRequestHeaders(), - /TrustBoundary: Invalid audience provided/, + (client as any).getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: Invalid audience provided/, ); stsScope.done(); }); - it('should pass in the impersonated service accounts trust boundary in the header', async () => { + 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`; @@ -2779,19 +2812,9 @@ describe('BaseExternalAccountClient', () => { }, ]); - const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( - '{universe_domain}', - 'googleapis.com', - ).replace('{service_account_email}', encodeURIComponent(saEmail)); - const tbScope = nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, EXPECTED_TB_DATA); - - const now = new Date().getTime(); const saSuccessResponse = { - accessToken: MOCK_ACCESS_TOKEN, - expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(Date.now() + 3600000).toISOString(), }; const impersonatedScope = mockGenerateAccessToken({ statusCode: 200, @@ -2800,17 +2823,77 @@ describe('BaseExternalAccountClient', () => { scopes: ['https://www.googleapis.com/auth/cloud-platform'], }); + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{universe_domain}', + 'googleapis.com', + ).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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (client as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - assert.deepStrictEqual(client.trustBoundary, EXPECTED_TB_DATA); stsScope.done(); - tbScope.done(); + rabScope.done(); impersonatedScope.done(); }); + + it('should clear cache and retry on stale RAB error', async () => { + const client = new TestExternalAccountClient(externalAccountOptions); + client.credentials = { + access_token: 'abc', + expiry_date: Date.now() + 100000, + }; + + // Seed the cache + client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + + // 1. First attempt with RAB header, returns 400 Stale + const scope1 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', EXPECTED_RAB_DATA.encodedLocations) + .reply(400, { + error: { + message: 'stale regional access boundary', + status: 'INVALID_ARGUMENT', + }, + }); + + // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK + const scope2 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', val => val === undefined) + .reply(200, {data: 'success'}); + + const res = await client.request({ + url: 'https://storage.googleapis.com/bucket/obj', + }); + + assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.deepStrictEqual(res.data, {data: 'success'}); + + scope1.done(); + scope2.done(); + }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.compute.ts b/packages/google-auth-library-nodejs/test/test.compute.ts index dd6cc6168..82c9a40bd 100644 --- a/packages/google-auth-library-nodejs/test/test.compute.ts +++ b/packages/google-auth-library-nodejs/test/test.compute.ts @@ -20,8 +20,8 @@ import * as sinon from 'sinon'; import {Compute, gcpMetadata} from '../src'; import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, - TrustBoundaryData, -} from '../src/auth/trustboundary'; + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -265,12 +265,12 @@ describe('compute', () => { assert.fail('failed to throw'); }); - describe('trust boundaries', () => { + describe('regional access boundaries', () => { let sandbox: sinon.SinonSandbox; const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; - const EXPECTED_TB_DATA: TrustBoundaryData = { + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['sadad', 'asdad'], encodedLocations: '000x9', }; @@ -289,9 +289,9 @@ describe('compute', () => { ); } - function setupTrustBoundaryNock( + function setupRegionalAccessBoundaryNock( email: string, - trustBoundaryData: TrustBoundaryData = EXPECTED_TB_DATA, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, ): nock.Scope { const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', @@ -300,21 +300,21 @@ describe('compute', () => { return nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, trustBoundaryData); + .reply(200, regionalAccessBoundaryData); } beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); - it('refreshTrustBoundary should use the email from metadataServer if no serviceAccountEmail passed', async () => { + 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'); @@ -323,36 +323,52 @@ describe('compute', () => { .withArgs('service-accounts/default/email') .resolves(fakeEmail); - const scopes = [ - setupTokenNock('default'), - setupTrustBoundaryNock(fakeEmail), - mockExample(), - ]; - - await compute.request({url}); + 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 as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, + ); - assert.deepStrictEqual(compute.trustBoundary, EXPECTED_TB_DATA); - scopes.forEach(s => s.done()); + tokenScope.done(); + rabScope.done(); }); - it('refreshTrustBoundary should throw when gcpMetadata call fails', async () => { + it('should fail getRegionalAccessBoundaryUrl in background if metadata call fails', async () => { const compute = new Compute(); - const scopes = [setupTokenNock()]; const metadataStub = sandbox.stub(gcpMetadata, 'instance'); metadataStub.callThrough(); metadataStub .withArgs('service-accounts/default/email') - .throws(new Error('sdfs')); + .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.request({url}), - new RegExp( - 'TrustBoundary: Failed to retrieve default service account email from metadata server.', - ), + (compute as any).getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: Failed to retrieve default service account email from metadata server./, ); - - scopes.forEach(s => s.done()); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts b/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts index 309847295..4cd24b769 100644 --- a/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts +++ b/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts @@ -32,9 +32,9 @@ import { import {DEFAULT_UNIVERSE} from '../src/auth/authclient'; import {TestUtils} from './utils'; import { - TrustBoundaryData, + RegionalAccessBoundaryData, WORKFORCE_LOOKUP_ENDPOINT, -} from '../src/auth/trustboundary'; +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -872,24 +872,24 @@ describe('ExternalAccountAuthorizedUserClient', () => { }); }); - describe('trust boundaries', () => { + describe('regional access boundaries', () => { const MOCK_ACCESS_TOKEN = 'newAccessToken'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; - const EXPECTED_TB_DATA: TrustBoundaryData = { + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['some-locations'], encodedLocations: '0xdeadbeef', }; beforeEach(() => { - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; nock.cleanAll(); }); - it('should fetch trust boundaries successfully', async () => { + it('should trigger asynchronous RAB refresh successfully', async () => { const workforcePoolId = 'pool-id-123'; const client = new ExternalAccountAuthorizedUserClient( externalAccountAuthorizedUserCredentialOptions, @@ -909,25 +909,42 @@ describe('ExternalAccountAuthorizedUserClient', () => { const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', - ).replace('{pool_id}', encodeURIComponent(workforcePoolId)); - const tbScope = nock(new URL(lookupUrl).origin) + ) + .replace('{location}', 'global') + .replace('{workforce_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(200, EXPECTED_TB_DATA); + .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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (client as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - assert.deepStrictEqual(client.trustBoundary, EXPECTED_TB_DATA); stsScope.done(); - tbScope.done(); + rabScope.done(); }); - it('should throw an error for an invalid audience', async () => { + it('should fail background lookup for an invalid audience', async () => { const invalidAudience = 'invalid-audience-format'; const options = { ...externalAccountAuthorizedUserCredentialOptions, @@ -949,12 +966,55 @@ describe('ExternalAccountAuthorizedUserClient', () => { }, ]); + // Note: background refresh fails silently in terms of getRequestHeaders resolving. + // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - client.getRequestHeaders(), - /TrustBoundary: A workforce pool ID is required for trust boundary lookups but could not be determined from the audience/, + (client as any).getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: A workforce pool ID is required for regional access boundary lookups but could not be determined from the audience/, ); stsScope.done(); }); + + it('should clear cache and retry on stale RAB error', async () => { + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + ); + // Seed with credentials + (client as any).credentials = { + access_token: MOCK_ACCESS_TOKEN, + expiry_date: Date.now() + 100000, + }; + + // Seed the RAB cache + client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + + // 1. First attempt with RAB header, returns 400 Stale + const scope1 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', EXPECTED_RAB_DATA.encodedLocations) + .reply(400, { + error: { + message: 'stale regional access boundary', + status: 'INVALID_ARGUMENT', + }, + }); + + // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK + const scope2 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', val => val === undefined) + .reply(200, {data: 'success'}); + + const res = await client.request({ + url: 'https://storage.googleapis.com/bucket/obj', + }); + + assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.deepStrictEqual(res.data, {data: 'success'}); + + scope1.done(); + scope2.done(); + }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts b/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts index 63ac15bde..bc4a8f130 100644 --- a/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts +++ b/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts @@ -42,6 +42,11 @@ import { } from '../src/auth/certificatesubjecttokensupplier'; import * as sinon from 'sinon'; import * as util from '../src/util'; +import { + RegionalAccessBoundaryData, + WORKFORCE_LOOKUP_ENDPOINT, + WORKLOAD_LOOKUP_ENDPOINT, +} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -1831,8 +1836,6 @@ describe('IdentityPoolClient', () => { it('should throw when one or more certs in trust chain is malformed', async () => { const certConfigPath = - './test/fixtures/external-account-cert/cert_config.json'; - const trustChainPath = './test/fixtures/external-account-cert/chain_with_malformed_cert.pem'; const certificateSourcedOptions: IdentityPoolClientOptions = { type: 'external_account', @@ -1841,8 +1844,9 @@ describe('IdentityPoolClient', () => { token_url: getMtlsTokenUrl(), credential_source: { certificate: { - certificate_config_location: certConfigPath, - trust_chain_path: trustChainPath, + certificate_config_location: + './test/fixtures/external-account-cert/cert_config.json', + trust_chain_path: certConfigPath, }, }, }; @@ -1852,13 +1856,196 @@ describe('IdentityPoolClient', () => { client.retrieveSubjectToken(), new RegExp( `Failed to parse certificate at index 0 in trust chain file ${ - trustChainPath + certConfigPath }`, ), ); }); }); }); + + 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 = { + ...fileSourcedOptions, + audience: workloadAudience, + }; + const client = new IdentityPoolClient(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: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const lookupUrl = WORKLOAD_LOOKUP_ENDPOINT.replace( + '{universe_domain}', + 'googleapis.com', + ) + .replace('{project_number}', projectNumber) + .replace('{workload_identity_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 as any).regionalAccessBoundary, + 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 = { + ...fileSourcedOptions, + audience: workforceAudience, + }; + const client = new IdentityPoolClient(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: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + }, + }, + ]); + + const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( + '{universe_domain}', + 'googleapis.com', + ) + .replace('{location}', location) + .replace('{workforce_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 as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, + ); + + stsScope.done(); + rabScope.done(); + }); + + it('should clear cache and retry on stale RAB error', async () => { + const client = new IdentityPoolClient(fileSourcedOptions); + // Seed with credentials + (client as any).credentials = { + access_token: MOCK_ACCESS_TOKEN, + expiry_date: Date.now() + 100000, + }; + + // Seed the RAB cache + client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + + // 1. First attempt with RAB header, returns 400 Stale + const scope1 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', EXPECTED_RAB_DATA.encodedLocations) + .reply(400, { + error: { + message: 'stale regional access boundary', + status: 'INVALID_ARGUMENT', + }, + }); + + // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK + const scope2 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', val => val === undefined) + .reply(200, {data: 'success'}); + + const res = await client.request({ + url: 'https://storage.googleapis.com/bucket/obj', + }); + + assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.deepStrictEqual(res.data, {data: 'success'}); + + scope1.done(); + scope2.done(); + }); + }); }); interface TestSubjectTokenSupplierOptions { diff --git a/packages/google-auth-library-nodejs/test/test.impersonated.ts b/packages/google-auth-library-nodejs/test/test.impersonated.ts index 79395aca1..013454944 100644 --- a/packages/google-auth-library-nodejs/test/test.impersonated.ts +++ b/packages/google-auth-library-nodejs/test/test.impersonated.ts @@ -21,8 +21,8 @@ import {Impersonated, JWT, UserRefreshClient} from '../src'; import {CredentialRequest} from '../src/auth/credentials'; import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, - TrustBoundaryData, -} from '../src/auth/trustboundary'; + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; import sinon = require('sinon'); const PEM_PATH = './test/fixtures/private.pem'; @@ -574,21 +574,21 @@ describe('impersonated', () => { scopes.forEach(s => s.done()); }); - describe('trust boundaries', () => { + describe('regional access boundaries', () => { let sandbox: sinon.SinonSandbox; const SOURCE_EMAIL = 'foo@serviceaccount.com'; const TARGET_PRINCIPAL_EMAIL = 'target@project.iam.gserviceaccount.com'; const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; - const EXPECTED_TB_DATA: TrustBoundaryData = { + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['sadad', 'asdad'], encodedLocations: '000x9', }; - function setupTrustBoundaryNock( + function setupRegionalAccessBoundaryNock( email: string, - trustBoundaryData: TrustBoundaryData = EXPECTED_TB_DATA, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, ): nock.Scope { const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', @@ -597,21 +597,21 @@ describe('impersonated', () => { return nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, trustBoundaryData); + .reply(200, regionalAccessBoundaryData); } beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); - it('should fetch trust boundaries successfully', async () => { + it('should trigger asynchronous RAB refresh', async () => { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const impersonated = new Impersonated({ @@ -622,75 +622,108 @@ describe('impersonated', () => { targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], }); - const scopes = [ - createGTokenMock({ - access_token: MOCK_ACCESS_TOKEN, - }), - nock('https://iamcredentials.googleapis.com') - .post( - '/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken', - (body: ImpersonatedCredentialRequest) => { - assert.strictEqual(body.lifetime, '30s'); - assert.deepStrictEqual(body.delegates, []); - assert.deepStrictEqual(body.scope, [ - 'https://www.googleapis.com/auth/cloud-platform', - ]); - return true; - }, - ) - .reply(200, { - accessToken: MOCK_ACCESS_TOKEN, - expireTime: tomorrow.toISOString(), - }), - setupTrustBoundaryNock(SOURCE_EMAIL), - setupTrustBoundaryNock(TARGET_PRINCIPAL_EMAIL), - ]; - const headers = await impersonated.getRequestHeaders(); + 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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (impersonated as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - scopes.forEach(s => s.done()); + + tokenScope.done(); + saScope.done(); + rabScope.done(); }); - it('should fail when no target principal is specified', async () => { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); + it('should fail getRegionalAccessBoundaryUrl in background if no target principal is specified', async () => { const impersonated = new Impersonated({ sourceClient: createSampleJWTClient(), - // targetPrincipal: TARGET_PRINCIPAL_EMAIL, + // targetPrincipal missing lifetime: 30, delegates: [], targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], }); - const scopes = [ - createGTokenMock({ - access_token: MOCK_ACCESS_TOKEN, - }), - nock('https://iamcredentials.googleapis.com') - .post( - '/v1/projects/-/serviceAccounts/:generateAccessToken', - (body: ImpersonatedCredentialRequest) => { - assert.strictEqual(body.lifetime, '30s'); - assert.deepStrictEqual(body.delegates, []); - assert.deepStrictEqual(body.scope, [ - 'https://www.googleapis.com/auth/cloud-platform', - ]); - return true; - }, - ) - .reply(200, { - accessToken: MOCK_ACCESS_TOKEN, - expireTime: tomorrow.toISOString(), - }), - setupTrustBoundaryNock(SOURCE_EMAIL), - ]; + // Error happens in background. await assert.rejects( - impersonated.getRequestHeaders(), - /TrustBoundary: A targetPrincipal is required for trust boundary lookups but was not provided in the ImpersonatedClient options./, + (impersonated as any).getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: A targetPrincipal is required for regional access boundary lookups but was not provided in the ImpersonatedClient options./, ); - scopes.forEach(s => s.done()); + }); + + it('should clear cache and retry on stale RAB error', 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'], + }); + // Seed with credentials + (impersonated as any).credentials = { + access_token: MOCK_ACCESS_TOKEN, + expiry_date: tomorrow.getTime(), + }; + + // Seed the RAB cache + impersonated.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + + // 1. First attempt with RAB header, returns 400 Stale + const scope1 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', EXPECTED_RAB_DATA.encodedLocations) + .reply(400, { + error: { + message: 'stale regional access boundary', + status: 'INVALID_ARGUMENT', + }, + }); + + // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK + const scope2 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', val => val === undefined) + .reply(200, {data: 'success'}); + + const res = await impersonated.request({ + url: 'https://storage.googleapis.com/bucket/obj', + }); + + assert.strictEqual((impersonated as any).regionalAccessBoundary, null); // Cache cleared + assert.deepStrictEqual(res.data, {data: 'success'}); + + scope1.done(); + scope2.done(); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.jwt.ts b/packages/google-auth-library-nodejs/test/test.jwt.ts index 66a4bbe02..ca1773912 100644 --- a/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -24,8 +24,8 @@ import {CredentialRequest, JWTInput} from '../src/auth/credentials'; import * as jwtaccess from '../src/auth/jwtaccess'; import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, - TrustBoundaryData, -} from '../src/auth/trustboundary'; + RegionalAccessBoundaryData, +} from '../src/auth/regionalaccessboundary'; import {GoogleToken} from 'gtoken'; function removeBearerFromAuthorizationHeader(headers: Headers): string { @@ -1244,20 +1244,21 @@ describe('jwt', () => { }); }); - describe('trust boundaries', () => { + 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_TB_DATA: TrustBoundaryData = { + const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['sadad', 'asdad'], encodedLocations: '000x9', }; - function setupTrustBoundaryNock( + function setupRegionalAccessBoundaryNock( email: string, - trustBoundaryData: TrustBoundaryData = EXPECTED_TB_DATA, + regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, + authHeader = MOCK_AUTH_HEADER, ): nock.Scope { const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', @@ -1265,22 +1266,22 @@ describe('jwt', () => { ).replace('{service_account_email}', encodeURIComponent(email)); return nock(new URL(lookupUrl).origin) .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(200, trustBoundaryData); + .matchHeader('authorization', authHeader) + .reply(200, regionalAccessBoundaryData); } beforeEach(() => { sandbox = sinon.createSandbox(); - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED'] = 'true'; + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); afterEach(() => { - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED']; + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; sandbox.restore(); nock.cleanAll(); }); - it('should fetch trust boundaries successfully', async () => { + it('should trigger asynchronous regional access boundaries refresh', async () => { const jwt = new JWT({ email: SERVICE_ACCOUNT_EMAIL, keyFile: PEM_PATH, @@ -1289,19 +1290,84 @@ describe('jwt', () => { }); jwt.credentials = {refresh_token: 'jwt-placeholder'}; - const scopes = [ - createGTokenMock({access_token: MOCK_ACCESS_TOKEN}), - setupTrustBoundaryNock(SERVICE_ACCOUNT_EMAIL), - ]; - const headers = await jwt.getRequestHeaders(); + 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( - headers.get('x-allowed-locations'), - EXPECTED_TB_DATA.encodedLocations, + (jwt as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, ); - scopes.forEach(s => s.done()); + + 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'}; + + let rabLookupCalled = false; + // For self-signed JWT, the lookup uses the JWT itself as the token + const rabScope = nock('https://iamcredentials.googleapis.com') + .get( + `/v1/projects/-/serviceAccounts/${encodeURIComponent(SERVICE_ACCOUNT_EMAIL)}/allowedLocations`, + ) + .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 as any).regionalAccessBoundary, + EXPECTED_RAB_DATA, + ); + + rabScope.done(); }); - it('should fail getTrustBoundaryUrl if no email is passed', async () => { + it('should fail getRegionalAccessBoundaryUrl if no email is passed', async () => { const jwt = new JWT({ keyFile: PEM_PATH, scopes: ['http://bar', 'http://foo'], @@ -1311,9 +1377,11 @@ describe('jwt', () => { jwt.credentials = {refresh_token: 'jwt-placeholder'}; const scopes = [createGTokenMock({access_token: MOCK_ACCESS_TOKEN})]; + // Note: error happens in background, so getRequestHeaders won't reject. + // But we can manually call getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - jwt.getRequestHeaders(), - /TrustBoundary: An email address is required for trust boundary lookups but was not provided in the JwtClient options./, + (jwt as any).getRegionalAccessBoundaryUrl(), + /RegionalAccessBoundary: An email address is required for regional access boundary lookups but was not provided in the JwtClient options./, ); scopes.forEach(s => s.done()); }); diff --git a/packages/google-auth-library-nodejs/test/test.oauth2.ts b/packages/google-auth-library-nodejs/test/test.oauth2.ts index fe4f656c9..56e6b9aca 100644 --- a/packages/google-auth-library-nodejs/test/test.oauth2.ts +++ b/packages/google-auth-library-nodejs/test/test.oauth2.ts @@ -1787,5 +1787,61 @@ describe('oauth2', () => { token: credentials.access_token, }); }); + + describe('regional access boundary recovery', () => { + const url = 'https://storage.googleapis.com/bucket/obj'; + const RAB_DATA = { + locations: ['us-central1'], + encodedLocations: '0x123', + }; + + it('should clear cache and retry on stale RAB error', async () => { + const client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + client.credentials = { + access_token: 'abc', + expiry_date: Date.now() + 100000, + }; + // Mocking the universe and experiment flag + process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; + (client as any).regionalAccessBoundaryEnabled = true; + + // Seed the cache + client.setRegionalAccessBoundary(RAB_DATA); + + // 1. First attempt with RAB header, returns 400 Stale + const scope1 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', RAB_DATA.encodedLocations) + .reply(400, { + error: { + message: 'stale regional access boundary', + status: 'INVALID_ARGUMENT', + }, + }); + + // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK + const scope2 = nock('https://storage.googleapis.com') + .get('/bucket/obj') + .matchHeader('x-allowed-locations', val => val === undefined) + .reply(200, {data: 'success'}); + + // 3. Background RAB lookup (triggered by retry) + // We need to mock this because getRequestMetadataAsync calls maybeTriggerRegionalAccessBoundaryRefresh + const rabUrl = + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations'; + // Note: we don't have an email in this test client, so it might fail or use 'default' depending on client type. + // For OAuth2Client base, getRegionalAccessBoundaryUrl returns null. + // If it returns null, background refresh is skipped. + + const res = await client.request({url}); + + assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.deepStrictEqual(res.data, {data: 'success'}); + + scope1.done(); + scope2.done(); + delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; + }); + }); }); }); From c6c1c7b99d4849a2b5127fd581aa3a596bb68e41 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 16 Jan 2026 16:59:43 -0800 Subject: [PATCH 2/3] Fixed unit tests moved RAB logic out of authclient. --- .../src/auth/authclient.ts | 250 ++++-------------- .../src/auth/baseexternalclient.ts | 23 +- .../externalAccountAuthorizedUserClient.ts | 21 +- .../src/auth/jwtclient.ts | 13 +- .../src/auth/oauth2client.ts | 28 +- .../src/auth/regionalaccessboundary.ts | 250 +++++++++++++++++- .../test/test.authclient.ts | 39 +-- .../test/test.baseexternalclient.ts | 55 ++-- .../test/test.compute.ts | 4 +- ...est.externalaccountauthorizeduserclient.ts | 49 ++-- .../test/test.externalclient.ts | 71 ++--- .../test/test.identitypoolclient.ts | 188 ------------- .../test/test.impersonated.ts | 11 +- .../test/test.jwt.ts | 17 +- .../test/test.oauth2.ts | 56 ---- 15 files changed, 456 insertions(+), 619 deletions(-) diff --git a/packages/google-auth-library-nodejs/src/auth/authclient.ts b/packages/google-auth-library-nodejs/src/auth/authclient.ts index 6d90b546a..9709811e5 100644 --- a/packages/google-auth-library-nodejs/src/auth/authclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/authclient.ts @@ -13,7 +13,13 @@ // limitations under the License. import {EventEmitter} from 'events'; -import {Gaxios, GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; +import { + Gaxios, + GaxiosError, + GaxiosOptions, + GaxiosPromise, + GaxiosResponse, +} from 'gaxios'; import {Credentials} from './credentials'; import {OriginalAndCamel, originalOrCamelOptions} from '../util'; @@ -23,6 +29,7 @@ import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs'; import { isRegionalAccessBoundaryEnabled, RegionalAccessBoundaryData, + RegionalAccessBoundaryManager, } from './regionalaccessboundary'; /** @@ -159,21 +166,6 @@ export const DEFAULT_UNIVERSE = 'googleapis.com'; */ export const DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS = 5 * 60 * 1000; -/** - * RAB is considered valid for 6 hours. - */ -const RAB_TTL_MILLIS = 6 * 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. - */ -const RAB_MAX_COOLDOWN_MILLIS = 24 * 60 * 60 * 1000; - /** * Defines the root interface for all clients that generate credentials * for calling Google APIs. All clients should implement this interface. @@ -252,11 +244,7 @@ export abstract class AuthClient forceRefreshOnFailure = false; universeDomain = DEFAULT_UNIVERSE; regionalAccessBoundaryEnabled: boolean; - protected regionalAccessBoundary?: RegionalAccessBoundaryData | null; - private regionalAccessBoundaryExpiry = 0; - private regionalAccessBoundaryRefreshPromise: Promise | null = null; - private regionalAccessBoundaryCooldownTime = 0; - private regionalAccessBoundaryCooldownBackoff = RAB_INITIAL_COOLDOWN_MILLIS; + protected regionalAccessBoundaryManager: RegionalAccessBoundaryManager; /** * Symbols that can be added to GaxiosOptions to specify the method name that is @@ -280,11 +268,16 @@ export abstract class AuthClient this.credentials = options.get('credentials') ?? {}; this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE; this.regionalAccessBoundaryEnabled = isRegionalAccessBoundaryEnabled(); - this.regionalAccessBoundary = null; // 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, @@ -395,8 +388,9 @@ export abstract class AuthClient * 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 */ - protected async getRegionalAccessBoundaryUrl(): Promise { + public async getRegionalAccessBoundaryUrl(): Promise { return null; } @@ -413,8 +407,22 @@ export abstract class AuthClient * @param data The regional access boundary data to set. */ setRegionalAccessBoundary(data: RegionalAccessBoundaryData) { - this.regionalAccessBoundary = data; - this.regionalAccessBoundaryExpiry = Date.now() + RAB_TTL_MILLIS; + this.regionalAccessBoundaryManager.setRegionalAccessBoundary(data); + } + + /** + * Returns the current regional access boundary data. + */ + getRegionalAccessBoundary(): RegionalAccessBoundaryData | null { + return this.regionalAccessBoundaryManager.data; + } + + /** + * Returns the current regional access boundary cooldown time in milliseconds. + * @internal + */ + getRegionalAccessBoundaryCooldownTime(): number { + return this.regionalAccessBoundaryManager.cooldownTime; } /** @@ -436,15 +444,10 @@ export abstract class AuthClient headers.set('x-goog-user-project', this.quotaProjectId); } - if ( - this.regionalAccessBoundaryEnabled && - this.regionalAccessBoundary && - this.regionalAccessBoundary.encodedLocations - ) { - headers.set( - 'x-allowed-locations', - this.regionalAccessBoundary.encodedLocations, - ); + const rabHeader = + this.regionalAccessBoundaryManager.getRegionalAccessBoundaryHeader(); + if (rabHeader) { + headers.set('x-allowed-locations', rabHeader); } return headers; @@ -617,171 +620,19 @@ export abstract class AuthClient }; } - /** - * Checks if the given URL is a global endpoint (not regional). - * @param url The URL to check. - */ - private isGlobalEndpoint(url?: string | URL): boolean { - if (!url) { - return true; - } - const hostname = url instanceof URL ? url.hostname : new URL(url).hostname; - return ( - !hostname.endsWith('.rep.googleapis.com') && - !hostname.endsWith('.rep.sandbox.googleapis.com') - ); - } - /** * Triggers an asynchronous regional access boundary refresh if needed. * @param url The endpoint URL being accessed. - */ - protected maybeTriggerRegionalAccessBoundaryRefresh(url?: string | URL) { - if ( - !this.regionalAccessBoundaryEnabled || - this.universeDomain !== DEFAULT_UNIVERSE || - !this.isGlobalEndpoint(url) || - this.regionalAccessBoundaryRefreshPromise - ) { - return; - } - - const now = Date.now(); - - // Check if in cooldown - if (now < this.regionalAccessBoundaryCooldownTime) { - return; - } - - // Check if expired or never fetched - if ( - !this.regionalAccessBoundary || - now >= this.regionalAccessBoundaryExpiry - ) { - this.regionalAccessBoundaryRefreshPromise = - this.backgroundRefreshRegionalAccessBoundary(); - } - } - - /** - * Performs the background refresh of the regional access boundary. - */ - private async backgroundRefreshRegionalAccessBoundary(): Promise { - try { - // Get tokens without triggering a recursive RAB lookup if possible. - // Most clients will have cached tokens or refresh them. - const tokens = await this.getAccessToken(); - - // Implement retry with exponential backoff for up to 1 minute. - let attempt = 0; - const startTime = Date.now(); - const maxRetryTime = 60 * 1000; - - while (true) { - try { - const data = await this.fetchRegionalAccessBoundary(tokens); - 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; - } - break; - } catch (error) { - const status = - (error as any).status || (error as any).response?.status; - const isRetryable = status >= 500 || status === 403 || status === 404; - - if (isRetryable && Date.now() - startTime < maxRetryTime) { - attempt++; - const delay = Math.min(Math.pow(2, attempt) * 100, 10000); - await new Promise(resolve => setTimeout(resolve, delay)); - continue; - } - - // Non-retryable or timeout: enter cooldown - this.regionalAccessBoundaryCooldownTime = - Date.now() + this.regionalAccessBoundaryCooldownBackoff; - this.regionalAccessBoundaryCooldownBackoff = Math.min( - this.regionalAccessBoundaryCooldownBackoff * 2, - RAB_MAX_COOLDOWN_MILLIS, - ); - AuthClient.log.error( - 'RegionalAccessBoundary: Lookup failed. Entering cooldown.', - error, - ); - break; - } - } - } catch (error) { - AuthClient.log.error( - 'RegionalAccessBoundary: Background refresh failed:', - error, - ); - } finally { - this.regionalAccessBoundaryRefreshPromise = null; - } - } - - /** - * Internal method to fetch RAB data. - */ - private async fetchRegionalAccessBoundary( - tokens: any, - ): Promise { - const regionalAccessBoundaryUrl = await this.getRegionalAccessBoundaryUrl(); - if (!regionalAccessBoundaryUrl) { - return null; - } - - const accessToken = tokens.token || tokens.access_token; - 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: { - httpMethodsToRetry: ['GET'], - }, - }, - headers, - url: regionalAccessBoundaryUrl, - }; - - const {data: regionalAccessBoundaryData} = - await this.transporter.request(opts); - - if (!regionalAccessBoundaryData.encodedLocations) { - throw new Error( - 'RegionalAccessBoundary: Malformed response from lookup endpoint.', - ); - } - - return regionalAccessBoundaryData; - } - - /** - * Refreshes regional access boundary data for an authenticated client. - * @deprecated Use maybeTriggerRegionalAccessBoundaryRefresh instead. - * @param tokens The refreshed credentials containing access token to call the regional access boundary endpoint. - * @returns A Promise resolving to RegionalAccessBoundaryData. - */ - protected async refreshRegionalAccessBoundary( - tokens: Credentials, - ): Promise { - // This is now handled asynchronously in backgroundRefreshRegionalAccessBoundary. - // Keeping it for backward compatibility but it just calls the internal fetch. - return this.fetchRegionalAccessBoundary(tokens); + * @param accessToken The access token to use for the lookup. + */ + protected maybeTriggerRegionalAccessBoundaryRefresh( + url: string | URL | undefined, + accessToken: string, + ) { + this.regionalAccessBoundaryManager.maybeTriggerRegionalAccessBoundaryRefresh( + url, + accessToken, + ); } /** @@ -802,10 +653,10 @@ export abstract class AuthClient * Checks if the error is a "stale regional access boundary" error. * @param error The error to check. */ - protected isStaleRegionalAccessBoundaryError(error: any): boolean { + public isStaleRegionalAccessBoundaryError(error: GaxiosError): boolean { const res = error.response; if (res && res.status === 400) { - const data = res.data; + const data = res.data as {error?: {message?: string}; message?: string}; const message = data?.error?.message || data?.message || error.message || ''; return message.toLowerCase().includes('stale regional access boundary'); @@ -817,8 +668,7 @@ export abstract class AuthClient * Clears the regional access boundary cache. */ protected clearRegionalAccessBoundaryCache() { - this.regionalAccessBoundary = null; - this.regionalAccessBoundaryExpiry = 0; + this.regionalAccessBoundaryManager.clearRegionalAccessBoundaryCache(); } } diff --git a/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts b/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts index 23c21cc4f..db44cc491 100644 --- a/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/baseexternalclient.ts @@ -76,7 +76,7 @@ export const CLOUD_RESOURCE_MANAGER = 'https://cloudresourcemanager.googleapis.com/v1/projects/'; /** The workforce audience pattern. */ const WORKFORCE_AUDIENCE_PATTERN = - '//iam.googleapis.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; + '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token'; /** @@ -424,12 +424,15 @@ 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.maybeTriggerRegionalAccessBoundaryRefresh(); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url, + accessTokenResponse.token!, + ); return this.addSharedMetadataHeaders(headers); } @@ -508,15 +511,19 @@ 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); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.applyHeadersFromSource(opts.headers, requestHeaders); + this.applyHeadersFromSource(requestOpts.headers, requestHeaders); - response = await this.transporter.request(opts); + response = await this.transporter.request(requestOpts); } catch (e) { - if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + if ( + this.isStaleRegionalAccessBoundaryError(e as GaxiosError) && + !reAuthRetried + ) { this.clearRegionalAccessBoundaryCache(); return await this.requestAsync(opts, true); } @@ -722,7 +729,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { return this.tokenUrl; } - protected async getRegionalAccessBoundaryUrl(): Promise { + public async getRegionalAccessBoundaryUrl(): Promise { if (this.serviceAccountImpersonationUrl) { // When impersonating a service account, the trust boundary is determined // by the security policies of the target service account. diff --git a/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts b/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts index 015eb4ddb..91a94c6f0 100644 --- a/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts +++ b/packages/google-auth-library-nodejs/src/auth/externalAccountAuthorizedUserClient.ts @@ -221,12 +221,15 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { }; } - async getRequestHeaders(): Promise { + async getRequestHeaders(url?: string | URL): Promise { const accessTokenResponse = await this.getAccessToken(); const headers = new Headers({ authorization: `Bearer ${accessTokenResponse.token}`, }); - this.maybeTriggerRegionalAccessBoundaryRefresh(); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url, + accessTokenResponse.token!, + ); return this.addSharedMetadataHeaders(headers); } @@ -260,15 +263,19 @@ 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); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.applyHeadersFromSource(opts.headers, requestHeaders); + this.applyHeadersFromSource(requestOpts.headers, requestHeaders); - response = await this.transporter.request(opts); + response = await this.transporter.request(requestOpts); } catch (e) { - if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + if ( + this.isStaleRegionalAccessBoundaryError(e as GaxiosError) && + !reAuthRetried + ) { this.clearRegionalAccessBoundaryCache(); return await this.requestAsync(opts, true); } @@ -332,7 +339,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { * does not support regional access boundaries. * @throws {Error} If the URL cannot be constructed for a compatible client. */ - protected async getRegionalAccessBoundaryUrl(): Promise { + public async getRegionalAccessBoundaryUrl(): Promise { const poolId = getWorkforcePoolIdFromAudience(this.audience); if (!poolId) { throw new Error( diff --git a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts index f5972ff65..2f0bf5b7a 100644 --- a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts @@ -141,7 +141,10 @@ export class JWT extends OAuth2Client implements IdTokenProvider { ).target_audience ) { const {tokens} = await this.refreshToken(); - this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url ?? undefined, + (tokens.access_token ?? tokens.id_token)!, + ); return { headers: this.addSharedMetadataHeaders( new Headers({ @@ -181,7 +184,13 @@ export class JWT extends OAuth2Client implements IdTokenProvider { useScopes ? scopes : undefined, ); - this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); + const authHeader = headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + this.maybeTriggerRegionalAccessBoundaryRefresh( + url ?? undefined, + authHeader.substring(7), + ); + } return {headers: this.addSharedMetadataHeaders(headers)}; } } else if (this.hasAnyScopes() || this.apiKey) { diff --git a/packages/google-auth-library-nodejs/src/auth/oauth2client.ts b/packages/google-auth-library-nodejs/src/auth/oauth2client.ts index 54cfe2b7e..3ddae3f68 100644 --- a/packages/google-auth-library-nodejs/src/auth/oauth2client.ts +++ b/packages/google-auth-library-nodejs/src/auth/oauth2client.ts @@ -957,13 +957,15 @@ export class OAuth2Client extends AuthClient { ); } - this.maybeTriggerRegionalAccessBoundaryRefresh(url ?? undefined); - if (thisCreds.access_token && !this.isTokenExpiring()) { thisCreds.token_type = thisCreds.token_type || 'Bearer'; const headers = new Headers({ authorization: thisCreds.token_type + ' ' + thisCreds.access_token, }); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url ?? undefined, + thisCreds.access_token, + ); return {headers: this.addSharedMetadataHeaders(headers)}; } @@ -976,6 +978,10 @@ export class OAuth2Client extends AuthClient { const headers = new Headers({ authorization: 'Bearer ' + this.credentials.access_token, }); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url ?? undefined, + this.credentials.access_token!, + ); return {headers: this.addSharedMetadataHeaders(headers)}; } } @@ -1006,6 +1012,10 @@ export class OAuth2Client extends AuthClient { const headers = new Headers({ authorization: credentials.token_type + ' ' + tokens.access_token, }); + this.maybeTriggerRegionalAccessBoundaryRefresh( + url ?? undefined, + tokens.access_token!, + ); return {headers: this.addSharedMetadataHeaders(headers), res: r.res}; } @@ -1119,19 +1129,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); + requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers); - this.applyHeadersFromSource(opts.headers, r.headers); + this.applyHeadersFromSource(requestOpts.headers, r.headers); if (this.apiKey) { - opts.headers.set('X-Goog-Api-Key', this.apiKey); + requestOpts.headers.set('X-Goog-Api-Key', this.apiKey); } - return await this.transporter.request(opts); + return await this.transporter.request(requestOpts); } catch (e) { - if (this.isStaleRegionalAccessBoundaryError(e) && !reAuthRetried) { + if ( + this.isStaleRegionalAccessBoundaryError(e as GaxiosError) && + !reAuthRetried + ) { this.clearRegionalAccessBoundaryCache(); // Background refresh is triggered by getRequestMetadataAsync in the retry return await this.requestAsync(opts, true); diff --git a/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts b/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts index 3af71e4ba..59637c030 100644 --- a/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts +++ b/packages/google-auth-library-nodejs/src/auth/regionalaccessboundary.ts @@ -12,9 +12,15 @@ // 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'); + // googleapis.com export const SERVICE_ACCOUNT_LOOKUP_ENDPOINT = - 'https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; + // 'https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; + 'https://staging-iamcredentials.sandbox.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations'; export const WORKLOAD_LOOKUP_ENDPOINT = 'https://iamcredentials.{universe_domain}/v1/projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations'; @@ -22,6 +28,21 @@ export const WORKLOAD_LOOKUP_ENDPOINT = export const WORKFORCE_LOOKUP_ENDPOINT = 'https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations'; +/** + * RAB is considered valid for 6 hours. + */ +const RAB_TTL_MILLIS = 6 * 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. + */ +const RAB_MAX_COOLDOWN_MILLIS = 24 * 60 * 60 * 1000; + /** * Holds regional access boundary related information like locations * where the credentials can be used. @@ -52,3 +73,230 @@ export function isRegionalAccessBoundaryEnabled() { } 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; + } + + /** + * Manually sets the regional access boundary data. + * Treating this as a standard cache entry with a 6-hour TTL. + * @param data The regional access boundary data to set. + */ + setRegionalAccessBoundary(data: RegionalAccessBoundaryData) { + this.regionalAccessBoundary = data; + this.regionalAccessBoundaryExpiry = Date.now() + RAB_TTL_MILLIS; + } + + /** + * Clears the regional access boundary cache. + */ + clearRegionalAccessBoundaryCache() { + this.regionalAccessBoundary = null; + this.regionalAccessBoundaryExpiry = 0; + } + + /** + * Returns the encoded locations string if the RAB is active and valid. + */ + getRegionalAccessBoundaryHeader(): string | null { + if ( + this.enabled && + this.regionalAccessBoundary && + this.regionalAccessBoundary.encodedLocations + ) { + 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 { + if (!url) { + return true; + } + const hostname = url instanceof URL ? url.hostname : new URL(url).hostname; + return ( + !hostname.endsWith('.rep.googleapis.com') && + !hostname.endsWith('.rep.sandbox.googleapis.com') + ); + } + + /** + * Triggers an asynchronous regional access boundary refresh if needed. + * @param url The endpoint URL being accessed. + * @param accessToken The access token to use for the lookup. + */ + maybeTriggerRegionalAccessBoundaryRefresh( + url: string | URL | undefined, + accessToken: string, + ) { + if ( + !this.enabled || + !this.options.isUniverseDomainDefault() || + !this.isGlobalEndpoint(url) || + this.regionalAccessBoundaryRefreshPromise + ) { + return; + } + + const now = Date.now(); + + // Check if in cooldown + if (now < this.regionalAccessBoundaryCooldownTime) { + return; + } + + // Check if expired or never fetched + if ( + !this.regionalAccessBoundary || + now >= this.regionalAccessBoundaryExpiry + ) { + 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 { + // Implement retry with exponential backoff for up to 1 minute. + let attempt = 0; + const startTime = Date.now(); + const maxRetryTime = 60 * 1000; + let shouldContinue = true; + + while (shouldContinue) { + 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; + } + shouldContinue = false; + } catch (error) { + const gaxiosError = error as { + status?: number; + response?: {status?: number}; + }; + const status = gaxiosError.status || gaxiosError.response?.status; + const isRetryable = + status !== undefined && + (status >= 500 || status === 403 || status === 404); + + if (isRetryable && Date.now() - startTime < maxRetryTime) { + attempt++; + const delay = Math.min(Math.pow(2, attempt) * 100, 10000); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + // Non-retryable or timeout: enter cooldown + 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, + ); + shouldContinue = false; + } + } + } catch (error) { + log.error('RegionalAccessBoundary: Background refresh failed:', error); + } finally { + this.regionalAccessBoundaryRefreshPromise = null; + } + } + + /** + * Internal method to fetch RAB data. + */ + 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: { + httpMethodsToRetry: ['GET'], + }, + }, + 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; + } +} diff --git a/packages/google-auth-library-nodejs/test/test.authclient.ts b/packages/google-auth-library-nodejs/test/test.authclient.ts index aab4767ad..f7a0980b7 100644 --- a/packages/google-auth-library-nodejs/test/test.authclient.ts +++ b/packages/google-auth-library-nodejs/test/test.authclient.ts @@ -394,7 +394,6 @@ describe('AuthClient', () => { describe('regional access boundaries', () => { const MOCK_ACCESS_TOKEN = 'abc123'; - const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; const SERVICE_ACCOUNT_EMAIL = 'service-account@example.com'; const EXPECTED_RAB_DATA: RegionalAccessBoundaryData = { locations: ['us-central1', 'europe-west1'], @@ -417,21 +416,6 @@ describe('AuthClient', () => { ); } - function setupRegionalAccessBoundaryNock( - email: string, - regionalAccessBoundaryData: RegionalAccessBoundaryData = EXPECTED_RAB_DATA, - statusCode = 200, - ): nock.Scope { - const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( - '{universe_domain}', - 'googleapis.com', - ).replace('{service_account_email}', encodeURIComponent(email)); - return nock(new URL(lookupUrl).origin) - .get(new URL(lookupUrl).pathname) - .matchHeader('authorization', MOCK_AUTH_HEADER) - .reply(statusCode, regionalAccessBoundaryData); - } - beforeEach(() => { process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); @@ -444,10 +428,8 @@ describe('AuthClient', () => { 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( @@ -471,7 +453,6 @@ describe('AuthClient', () => { ); assert.strictEqual(headers.get('x-allowed-locations'), null); - // assert.strictEqual(compute.regionalAccessBoundary, 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 @@ -486,7 +467,7 @@ describe('AuthClient', () => { // Give the background processing a moment to update the class member await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (compute as any).regionalAccessBoundary, + compute.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -549,13 +530,13 @@ describe('AuthClient', () => { // Wait for retries (exponential backoff might take a moment) let attempts = 0; - while (!(compute as any).regionalAccessBoundary && attempts < 20) { + while (!compute.getRegionalAccessBoundary() && attempts < 20) { await new Promise(r => setTimeout(r, 150)); attempts++; } assert.deepStrictEqual( - (compute as any).regionalAccessBoundary, + compute.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); rabFail.done(); @@ -586,16 +567,14 @@ describe('AuthClient', () => { // Wait for it to fail and enter cooldown let attempts = 0; while ( - !(compute as any).regionalAccessBoundaryCooldownTime && + !compute.getRegionalAccessBoundaryCooldownTime() && attempts < 10 ) { await new Promise(r => setTimeout(r, 50)); attempts++; } - assert.ok( - (compute as any).regionalAccessBoundaryCooldownTime > Date.now(), - ); + 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'); @@ -634,10 +613,10 @@ describe('AuthClient', () => { }, }, }, - }; + } as GaxiosError; assert.strictEqual( - (compute as any).isStaleRegionalAccessBoundaryError(error), + compute.isStaleRegionalAccessBoundaryError(error), true, ); @@ -646,9 +625,9 @@ describe('AuthClient', () => { status: 400, data: {message: 'Something else'}, }, - }; + } as GaxiosError; assert.strictEqual( - (compute as any).isStaleRegionalAccessBoundaryError(otherError), + compute.isStaleRegionalAccessBoundaryError(otherError), false, ); }); diff --git a/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts b/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts index af73168f4..6ee88f01d 100644 --- a/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts +++ b/packages/google-auth-library-nodejs/test/test.baseexternalclient.ts @@ -2656,8 +2656,8 @@ describe('BaseExternalAccountClient', () => { '{universe_domain}', 'googleapis.com', ) - .replace('{project_number}', projectNumber) - .replace('{workload_identity_pool_id}', workloadPoolId); + .replace('{project_id}', projectNumber) + .replace('{pool_id}', workloadPoolId); let rabLookupCalled = false; const rabScope = nock(new URL(lookupUrl).origin) @@ -2682,7 +2682,7 @@ describe('BaseExternalAccountClient', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (client as any).regionalAccessBoundary, + client.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -2721,7 +2721,7 @@ describe('BaseExternalAccountClient', () => { 'googleapis.com', ) .replace('{location}', location) - .replace('{workforce_pool_id}', workforcePoolId); + .replace('{pool_id}', workforcePoolId); let rabLookupCalled = false; const rabScope = nock(new URL(lookupUrl).origin) @@ -2744,7 +2744,7 @@ describe('BaseExternalAccountClient', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (client as any).regionalAccessBoundary, + client.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -2760,30 +2760,12 @@ describe('BaseExternalAccountClient', () => { }; const client = new TestExternalAccountClient(invalidOptions); - const stsScope = mockStsTokenExchange([ - { - statusCode: 200, - response: {...stsSuccessfulResponse, access_token: MOCK_ACCESS_TOKEN}, - request: { - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - audience: invalidAudience, - 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', - }, - }, - ]); - // Note: background refresh fails silently in terms of getRequestHeaders resolving. // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - (client as any).getRegionalAccessBoundaryUrl(), + client.getRegionalAccessBoundaryUrl(), /RegionalAccessBoundary: Invalid audience provided/, ); - - stsScope.done(); }); it('should trigger asynchronous RAB refresh for impersonated service account', async () => { @@ -2849,7 +2831,7 @@ describe('BaseExternalAccountClient', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (client as any).regionalAccessBoundary, + client.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -2860,10 +2842,26 @@ describe('BaseExternalAccountClient', () => { it('should clear cache and retry on stale RAB error', async () => { const client = new TestExternalAccountClient(externalAccountOptions); - client.credentials = { + client.setCredentials({ access_token: 'abc', expiry_date: Date.now() + 100000, - }; + }); + + const stsScope = mockStsTokenExchange([ + { + statusCode: 200, + response: {...stsSuccessfulResponse, access_token: MOCK_ACCESS_TOKEN}, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: audience, + 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', + }, + }, + ]); // Seed the cache client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); @@ -2889,11 +2887,12 @@ describe('BaseExternalAccountClient', () => { url: 'https://storage.googleapis.com/bucket/obj', }); - assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.strictEqual(client.getRegionalAccessBoundary(), null); // Cache cleared assert.deepStrictEqual(res.data, {data: 'success'}); scope1.done(); scope2.done(); + stsScope.done(); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.compute.ts b/packages/google-auth-library-nodejs/test/test.compute.ts index 82c9a40bd..5d91995dc 100644 --- a/packages/google-auth-library-nodejs/test/test.compute.ts +++ b/packages/google-auth-library-nodejs/test/test.compute.ts @@ -346,7 +346,7 @@ describe('compute', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (compute as any).regionalAccessBoundary, + compute.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -366,7 +366,7 @@ describe('compute', () => { // Error happens in background, so getRequestHeaders resolves fine. // We manually call getRegionalAccessBoundaryUrl to verify the failure logic. await assert.rejects( - (compute as any).getRegionalAccessBoundaryUrl(), + compute.getRegionalAccessBoundaryUrl(), /RegionalAccessBoundary: Failed to retrieve default service account email from metadata server./, ); }); diff --git a/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts b/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts index 4cd24b769..cb3020cc9 100644 --- a/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts +++ b/packages/google-auth-library-nodejs/test/test.externalaccountauthorizeduserclient.ts @@ -881,6 +881,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { }; beforeEach(() => { + clock.restore(); process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; }); @@ -909,9 +910,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( '{universe_domain}', 'googleapis.com', - ) - .replace('{location}', 'global') - .replace('{workforce_pool_id}', encodeURIComponent(workforcePoolId)); + ).replace('{pool_id}', encodeURIComponent(workforcePoolId)); let rabLookupCalled = false; const rabScope = nock(new URL(lookupUrl).origin) @@ -928,15 +927,15 @@ describe('ExternalAccountAuthorizedUserClient', () => { // Wait for background lookup let attempts = 0; - while (!rabLookupCalled && attempts < 10) { - await new Promise(r => setTimeout(r, 50)); + while (!rabLookupCalled && attempts < 20) { + await new Promise(r => setTimeout(r, 100)); attempts++; } assert.strictEqual(rabLookupCalled, true); - await new Promise(r => setTimeout(r, 50)); + await new Promise(r => setTimeout(r, 100)); assert.deepStrictEqual( - (client as any).regionalAccessBoundary, + client.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -952,28 +951,12 @@ describe('ExternalAccountAuthorizedUserClient', () => { }; const client = new ExternalAccountAuthorizedUserClient(options); - const stsScope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ - { - statusCode: 200, - response: { - ...successfulRefreshResponse, - access_token: MOCK_ACCESS_TOKEN, - }, - request: { - grant_type: 'refresh_token', - refresh_token: 'refreshToken', - }, - }, - ]); - // Note: background refresh fails silently in terms of getRequestHeaders resolving. // But we can manually trigger getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - (client as any).getRegionalAccessBoundaryUrl(), + client.getRegionalAccessBoundaryUrl(), /RegionalAccessBoundary: A workforce pool ID is required for regional access boundary lookups but could not be determined from the audience/, ); - - stsScope.done(); }); it('should clear cache and retry on stale RAB error', async () => { @@ -981,14 +964,25 @@ describe('ExternalAccountAuthorizedUserClient', () => { externalAccountAuthorizedUserCredentialOptions, ); // Seed with credentials - (client as any).credentials = { + client.setCredentials({ access_token: MOCK_ACCESS_TOKEN, expiry_date: Date.now() + 100000, - }; + }); // Seed the RAB cache client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); + const stsScope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + // 1. First attempt with RAB header, returns 400 Stale const scope1 = nock('https://storage.googleapis.com') .get('/bucket/obj') @@ -1010,11 +1004,12 @@ describe('ExternalAccountAuthorizedUserClient', () => { url: 'https://storage.googleapis.com/bucket/obj', }); - assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared + assert.strictEqual(client.getRegionalAccessBoundary(), null); // Cache cleared assert.deepStrictEqual(res.data, {data: 'success'}); scope1.done(); scope2.done(); + stsScope.done(); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.externalclient.ts b/packages/google-auth-library-nodejs/test/test.externalclient.ts index d85a420b7..6c64d3601 100644 --- a/packages/google-auth-library-nodejs/test/test.externalclient.ts +++ b/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/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts b/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts index bc4a8f130..11ac50bb5 100644 --- a/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts +++ b/packages/google-auth-library-nodejs/test/test.identitypoolclient.ts @@ -42,11 +42,6 @@ import { } from '../src/auth/certificatesubjecttokensupplier'; import * as sinon from 'sinon'; import * as util from '../src/util'; -import { - RegionalAccessBoundaryData, - WORKFORCE_LOOKUP_ENDPOINT, - WORKLOAD_LOOKUP_ENDPOINT, -} from '../src/auth/regionalaccessboundary'; nock.disableNetConnect(); @@ -1863,189 +1858,6 @@ describe('IdentityPoolClient', () => { }); }); }); - - 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 = { - ...fileSourcedOptions, - audience: workloadAudience, - }; - const client = new IdentityPoolClient(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: fileSubjectToken, - subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - }, - }, - ]); - - const lookupUrl = WORKLOAD_LOOKUP_ENDPOINT.replace( - '{universe_domain}', - 'googleapis.com', - ) - .replace('{project_number}', projectNumber) - .replace('{workload_identity_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 as any).regionalAccessBoundary, - 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 = { - ...fileSourcedOptions, - audience: workforceAudience, - }; - const client = new IdentityPoolClient(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: fileSubjectToken, - subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', - }, - }, - ]); - - const lookupUrl = WORKFORCE_LOOKUP_ENDPOINT.replace( - '{universe_domain}', - 'googleapis.com', - ) - .replace('{location}', location) - .replace('{workforce_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 as any).regionalAccessBoundary, - EXPECTED_RAB_DATA, - ); - - stsScope.done(); - rabScope.done(); - }); - - it('should clear cache and retry on stale RAB error', async () => { - const client = new IdentityPoolClient(fileSourcedOptions); - // Seed with credentials - (client as any).credentials = { - access_token: MOCK_ACCESS_TOKEN, - expiry_date: Date.now() + 100000, - }; - - // Seed the RAB cache - client.setRegionalAccessBoundary(EXPECTED_RAB_DATA); - - // 1. First attempt with RAB header, returns 400 Stale - const scope1 = nock('https://storage.googleapis.com') - .get('/bucket/obj') - .matchHeader('x-allowed-locations', EXPECTED_RAB_DATA.encodedLocations) - .reply(400, { - error: { - message: 'stale regional access boundary', - status: 'INVALID_ARGUMENT', - }, - }); - - // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK - const scope2 = nock('https://storage.googleapis.com') - .get('/bucket/obj') - .matchHeader('x-allowed-locations', val => val === undefined) - .reply(200, {data: 'success'}); - - const res = await client.request({ - url: 'https://storage.googleapis.com/bucket/obj', - }); - - assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared - assert.deepStrictEqual(res.data, {data: 'success'}); - - scope1.done(); - scope2.done(); - }); - }); }); interface TestSubjectTokenSupplierOptions { diff --git a/packages/google-auth-library-nodejs/test/test.impersonated.ts b/packages/google-auth-library-nodejs/test/test.impersonated.ts index 013454944..32d1ae792 100644 --- a/packages/google-auth-library-nodejs/test/test.impersonated.ts +++ b/packages/google-auth-library-nodejs/test/test.impersonated.ts @@ -576,7 +576,6 @@ describe('impersonated', () => { describe('regional access boundaries', () => { let sandbox: sinon.SinonSandbox; - const SOURCE_EMAIL = 'foo@serviceaccount.com'; const TARGET_PRINCIPAL_EMAIL = 'target@project.iam.gserviceaccount.com'; const MOCK_ACCESS_TOKEN = 'abc123'; const MOCK_AUTH_HEADER = `Bearer ${MOCK_ACCESS_TOKEN}`; @@ -654,7 +653,7 @@ describe('impersonated', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (impersonated as any).regionalAccessBoundary, + impersonated.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -674,7 +673,7 @@ describe('impersonated', () => { // Error happens in background. await assert.rejects( - (impersonated as any).getRegionalAccessBoundaryUrl(), + impersonated.getRegionalAccessBoundaryUrl(), /RegionalAccessBoundary: A targetPrincipal is required for regional access boundary lookups but was not provided in the ImpersonatedClient options./, ); }); @@ -690,10 +689,10 @@ describe('impersonated', () => { targetScopes: ['https://www.googleapis.com/auth/cloud-platform'], }); // Seed with credentials - (impersonated as any).credentials = { + impersonated.setCredentials({ access_token: MOCK_ACCESS_TOKEN, expiry_date: tomorrow.getTime(), - }; + }); // Seed the RAB cache impersonated.setRegionalAccessBoundary(EXPECTED_RAB_DATA); @@ -719,7 +718,7 @@ describe('impersonated', () => { url: 'https://storage.googleapis.com/bucket/obj', }); - assert.strictEqual((impersonated as any).regionalAccessBoundary, null); // Cache cleared + assert.strictEqual(impersonated.getRegionalAccessBoundary(), null); // Cache cleared assert.deepStrictEqual(res.data, {data: 'success'}); scope1.done(); diff --git a/packages/google-auth-library-nodejs/test/test.jwt.ts b/packages/google-auth-library-nodejs/test/test.jwt.ts index ca1773912..3dbe3835e 100644 --- a/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -26,7 +26,6 @@ import { SERVICE_ACCOUNT_LOOKUP_ENDPOINT, RegionalAccessBoundaryData, } from '../src/auth/regionalaccessboundary'; -import {GoogleToken} from 'gtoken'; function removeBearerFromAuthorizationHeader(headers: Headers): string { return (headers.get('authorization') || '').replace('Bearer ', ''); @@ -1315,7 +1314,7 @@ describe('jwt', () => { // Give it a moment to update state await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (jwt as any).regionalAccessBoundary, + jwt.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -1360,7 +1359,7 @@ describe('jwt', () => { await new Promise(r => setTimeout(r, 50)); assert.deepStrictEqual( - (jwt as any).regionalAccessBoundary, + jwt.getRegionalAccessBoundary(), EXPECTED_RAB_DATA, ); @@ -1373,17 +1372,15 @@ describe('jwt', () => { scopes: ['http://bar', 'http://foo'], subject: 'bar@subjectaccount.com', }); - jwt.gtoken = new GoogleToken({email: 'adas@GA.com', keyFile: PEM_PATH}); - jwt.credentials = {refresh_token: 'jwt-placeholder'}; + // Ensure email is explicitly undefined + jwt.email = undefined; - const scopes = [createGTokenMock({access_token: MOCK_ACCESS_TOKEN})]; - // Note: error happens in background, so getRequestHeaders won't reject. - // But we can manually call getRegionalAccessBoundaryUrl to verify it throws. + // Note: error happens in background during getRequestHeaders, + // but we can manually call getRegionalAccessBoundaryUrl to verify it throws. await assert.rejects( - (jwt as any).getRegionalAccessBoundaryUrl(), + jwt.getRegionalAccessBoundaryUrl(), /RegionalAccessBoundary: An email address is required for regional access boundary lookups but was not provided in the JwtClient options./, ); - scopes.forEach(s => s.done()); }); }); }); diff --git a/packages/google-auth-library-nodejs/test/test.oauth2.ts b/packages/google-auth-library-nodejs/test/test.oauth2.ts index 56e6b9aca..fe4f656c9 100644 --- a/packages/google-auth-library-nodejs/test/test.oauth2.ts +++ b/packages/google-auth-library-nodejs/test/test.oauth2.ts @@ -1787,61 +1787,5 @@ describe('oauth2', () => { token: credentials.access_token, }); }); - - describe('regional access boundary recovery', () => { - const url = 'https://storage.googleapis.com/bucket/obj'; - const RAB_DATA = { - locations: ['us-central1'], - encodedLocations: '0x123', - }; - - it('should clear cache and retry on stale RAB error', async () => { - const client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); - client.credentials = { - access_token: 'abc', - expiry_date: Date.now() + 100000, - }; - // Mocking the universe and experiment flag - process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT'] = 'true'; - (client as any).regionalAccessBoundaryEnabled = true; - - // Seed the cache - client.setRegionalAccessBoundary(RAB_DATA); - - // 1. First attempt with RAB header, returns 400 Stale - const scope1 = nock('https://storage.googleapis.com') - .get('/bucket/obj') - .matchHeader('x-allowed-locations', RAB_DATA.encodedLocations) - .reply(400, { - error: { - message: 'stale regional access boundary', - status: 'INVALID_ARGUMENT', - }, - }); - - // 2. Second attempt (retry) WITHOUT RAB header, returns 200 OK - const scope2 = nock('https://storage.googleapis.com') - .get('/bucket/obj') - .matchHeader('x-allowed-locations', val => val === undefined) - .reply(200, {data: 'success'}); - - // 3. Background RAB lookup (triggered by retry) - // We need to mock this because getRequestMetadataAsync calls maybeTriggerRegionalAccessBoundaryRefresh - const rabUrl = - 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/default/allowedLocations'; - // Note: we don't have an email in this test client, so it might fail or use 'default' depending on client type. - // For OAuth2Client base, getRegionalAccessBoundaryUrl returns null. - // If it returns null, background refresh is skipped. - - const res = await client.request({url}); - - assert.strictEqual((client as any).regionalAccessBoundary, null); // Cache cleared - assert.deepStrictEqual(res.data, {data: 'success'}); - - scope1.done(); - scope2.done(); - delete process.env['GOOGLE_AUTH_TRUST_BOUNDARY_ENABLE_EXPERIMENT']; - }); - }); }); }); From 635bae2001a120385a159ec273abf092815f42b6 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 16 Jan 2026 17:27:08 -0800 Subject: [PATCH 3/3] Changed getRegionalAccessBoundaryUrl and fixed a self-signed jwt failure. --- .../src/auth/computeclient.ts | 2 +- .../src/auth/impersonated.ts | 2 +- .../src/auth/jwtclient.ts | 2 +- .../google-auth-library-nodejs/test/test.jwt.ts | 14 ++++++++++---- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/google-auth-library-nodejs/src/auth/computeclient.ts b/packages/google-auth-library-nodejs/src/auth/computeclient.ts index a1a3dc015..14ef044d3 100644 --- a/packages/google-auth-library-nodejs/src/auth/computeclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/computeclient.ts @@ -139,7 +139,7 @@ export class Compute extends OAuth2Client { } } - protected async getRegionalAccessBoundaryUrl(): Promise { + public async getRegionalAccessBoundaryUrl(): Promise { const email = await this.resolveServiceAccountEmail(); const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( '{universe_domain}', diff --git a/packages/google-auth-library-nodejs/src/auth/impersonated.ts b/packages/google-auth-library-nodejs/src/auth/impersonated.ts index 372c775e8..854086d23 100644 --- a/packages/google-auth-library-nodejs/src/auth/impersonated.ts +++ b/packages/google-auth-library-nodejs/src/auth/impersonated.ts @@ -263,7 +263,7 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { return res.data.token; } - protected async getRegionalAccessBoundaryUrl(): Promise { + public async getRegionalAccessBoundaryUrl(): Promise { const targetPrincipal = this.getTargetPrincipal(); if (!targetPrincipal) { throw new Error( diff --git a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts index 2f0bf5b7a..ff8b43e5f 100644 --- a/packages/google-auth-library-nodejs/src/auth/jwtclient.ts +++ b/packages/google-auth-library-nodejs/src/auth/jwtclient.ts @@ -420,7 +420,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { throw new Error('A key or a keyFile must be provided to getCredentials.'); } - protected async getRegionalAccessBoundaryUrl(): Promise { + 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.', diff --git a/packages/google-auth-library-nodejs/test/test.jwt.ts b/packages/google-auth-library-nodejs/test/test.jwt.ts index 3dbe3835e..45f6f1897 100644 --- a/packages/google-auth-library-nodejs/test/test.jwt.ts +++ b/packages/google-auth-library-nodejs/test/test.jwt.ts @@ -1331,12 +1331,18 @@ describe('jwt', () => { }); jwt.credentials = {refresh_token: 'jwt-placeholder'}; + const lookupUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace( + '{universe_domain}', + 'googleapis.com', + ).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('https://iamcredentials.googleapis.com') - .get( - `/v1/projects/-/serviceAccounts/${encodeURIComponent(SERVICE_ACCOUNT_EMAIL)}/allowedLocations`, - ) + const rabScope = nock(new URL(lookupUrl).origin) + .get(new URL(lookupUrl).pathname) .reply(() => { rabLookupCalled = true; return [200, EXPECTED_RAB_DATA];