Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 85 additions & 97 deletions packages/google-auth-library-nodejs/src/auth/authclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,24 @@
// 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';
import {log as makeLog} from 'google-logging-utils';

import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs';
import {
isTrustBoundaryEnabled,
NoOpEncodedLocations,
TrustBoundaryData,
} from './trustboundary';
isRegionalAccessBoundaryEnabled,
RegionalAccessBoundaryData,
RegionalAccessBoundaryManager,
} from './regionalaccessboundary';

/**
* An interface for enforcing `fetch`-type compliance.
Expand Down Expand Up @@ -237,8 +243,8 @@ export abstract class AuthClient
eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS;
forceRefreshOnFailure = false;
universeDomain = DEFAULT_UNIVERSE;
trustBoundaryEnabled: boolean;
trustBoundary?: TrustBoundaryData | null;
regionalAccessBoundaryEnabled: boolean;
protected regionalAccessBoundaryManager: RegionalAccessBoundaryManager;

/**
* Symbols that can be added to GaxiosOptions to specify the method name that is
Expand All @@ -261,12 +267,17 @@ export abstract class AuthClient
this.quotaProjectId = options.get('quota_project_id');
this.credentials = options.get('credentials') ?? {};
this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE;
this.trustBoundaryEnabled = isTrustBoundaryEnabled();
this.trustBoundary = null;
this.regionalAccessBoundaryEnabled = isRegionalAccessBoundaryEnabled();

// Shared client options
this.transporter = opts.transporter ?? new Gaxios(opts.transporterOptions);

this.regionalAccessBoundaryManager = new RegionalAccessBoundaryManager({
transporter: this.transporter,
getLookupUrl: async () => this.getRegionalAccessBoundaryUrl(),
isUniverseDomainDefault: () => this.universeDomain === DEFAULT_UNIVERSE,
});

if (options.get('useAuthRequestParameters') !== false) {
this.transporter.interceptors.request.add(
AuthClient.DEFAULT_REQUEST_INTERCEPTOR,
Expand Down Expand Up @@ -371,14 +382,15 @@ 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.
* @internal
*/
protected async getTrustBoundaryUrl(): Promise<string | null> {
public async getRegionalAccessBoundaryUrl(): Promise<string | null> {
return null;
}

Expand All @@ -389,6 +401,30 @@ 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.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;
}

/**
* Append additional headers, e.g., x-goog-user-project, shared across the
* classes inheriting AuthClient. This method should be used by any method
Expand All @@ -408,14 +444,10 @@ 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.
headers.set(
'x-allowed-locations',
this.trustBoundary.encodedLocations === NoOpEncodedLocations
? ''
: this.trustBoundary.encodedLocations,
);
const rabHeader =
this.regionalAccessBoundaryManager.getRegionalAccessBoundaryHeader();
if (rabHeader) {
headers.set('x-allowed-locations', rabHeader);
}

return headers;
Expand Down Expand Up @@ -589,84 +621,18 @@ 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.
* 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.
*/
protected async refreshTrustBoundary(
tokens: Credentials,
): Promise<TrustBoundaryData | null> {
if (!this.trustBoundaryEnabled) {
return null;
}

if (this.universeDomain !== DEFAULT_UNIVERSE) {
// Skipping check for non-default universe domain as this feature is only supported in GDU
return null;
}

const cachedTB = this.trustBoundary;
if (cachedTB && cachedTB.encodedLocations === NoOpEncodedLocations) {
return cachedTB;
}

const trustBoundaryUrl = await this.getTrustBoundaryUrl();
if (!trustBoundaryUrl) {
return null;
}

const accessToken = tokens.access_token;

if (!accessToken || this.isExpired(tokens)) {
throw new Error(
'TrustBoundary: 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,
}),
protected maybeTriggerRegionalAccessBoundaryRefresh(
url: string | URL | undefined,
accessToken: string,
) {
this.regionalAccessBoundaryManager.maybeTriggerRegionalAccessBoundaryRefresh(
url,
accessToken,
);

const opts: GaxiosOptions = {
...{
retry: true,
retryConfig: {
httpMethodsToRetry: ['GET'],
},
},
headers,
url: trustBoundaryUrl,
};

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<TrustBoundaryData>(opts);

if (!trustBoundaryData.encodedLocations) {
throw new Error(
'TrustBoundary: Malformed response from lookup endpoint.',
);
}

return trustBoundaryData;
} catch (error) {
if (this.trustBoundary) {
return this.trustBoundary; // return cached tb if call to lookup fails
}
throw new Error(
'TrustBoundary: Failure while getting trust boundaries:',
{
cause: error,
},
);
}
}

/**
Expand All @@ -682,6 +648,28 @@ 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.
*/
public isStaleRegionalAccessBoundaryError(error: GaxiosError): boolean {
const res = error.response;
if (res && res.status === 400) {
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');
}
return false;
}

/**
* Clears the regional access boundary cache.
*/
protected clearRegionalAccessBoundaryCache() {
this.regionalAccessBoundaryManager.clearRegionalAccessBoundaryCache();
}
}

// TypeScript does not have `HeadersInit` in the standard types yet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -424,11 +424,15 @@ export abstract class BaseExternalAccountClient extends AuthClient {
* The result has the form:
* { authorization: 'Bearer <access_token_value>' }
*/
async getRequestHeaders(): Promise<Headers> {
async getRequestHeaders(url?: string | URL): Promise<Headers> {
const accessTokenResponse = await this.getAccessToken();
const headers = new Headers({
authorization: `Bearer ${accessTokenResponse.token}`,
});
this.maybeTriggerRegionalAccessBoundaryRefresh(
url,
accessTokenResponse.token!,
);
return this.addSharedMetadataHeaders(headers);
}

Expand Down Expand Up @@ -507,14 +511,22 @@ export abstract class BaseExternalAccountClient extends AuthClient {
reAuthRetried = false,
): Promise<GaxiosResponse<T>> {
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<T>(opts);
response = await this.transporter.request<T>(requestOpts);
} catch (e) {
if (
this.isStaleRegionalAccessBoundaryError(e as GaxiosError) &&
!reAuthRetried
) {
this.clearRegionalAccessBoundaryCache();
return await this.requestAsync<T>(opts, true);
}
const res = (e as GaxiosError).response;
if (res) {
const statusCode = res.status;
Expand Down Expand Up @@ -624,7 +636,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,
Expand Down Expand Up @@ -718,14 +729,14 @@ export abstract class BaseExternalAccountClient extends AuthClient {
return this.tokenUrl;
}

protected async getTrustBoundaryUrl(): Promise<string> {
public async getRegionalAccessBoundaryUrl(): Promise<string> {
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(
Expand Down Expand Up @@ -753,7 +764,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.`,
);
}
}
11 changes: 5 additions & 6 deletions packages/google-auth-library-nodejs/src/auth/computeclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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};
}
Expand Down Expand Up @@ -140,13 +139,13 @@ export class Compute extends OAuth2Client {
}
}

protected async getTrustBoundaryUrl(): Promise<string> {
public async getRegionalAccessBoundaryUrl(): Promise<string> {
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;
}

/**
Expand All @@ -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,
},
Expand Down
Loading