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
88 changes: 86 additions & 2 deletions core/packages/google-auth-library-nodejs/src/auth/authclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {OriginalAndCamel, originalOrCamelOptions} from '../util';
import {log as makeLog} from 'google-logging-utils';

import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs';
import {
RegionalAccessBoundaryData,
RegionalAccessBoundaryManager,
} from './regionalaccessboundary';

/**
* An interface for enforcing `fetch`-type compliance.
Expand Down Expand Up @@ -232,6 +236,7 @@ export abstract class AuthClient
eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS;
forceRefreshOnFailure = false;
universeDomain = DEFAULT_UNIVERSE;
protected regionalAccessBoundaryManager: RegionalAccessBoundaryManager;

/**
* Symbols that can be added to GaxiosOptions to specify the method name that is
Expand All @@ -258,6 +263,12 @@ export abstract class AuthClient
// 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 @@ -361,13 +372,44 @@ export abstract class AuthClient
res?: GaxiosResponse | null;
}>;

/**
* Returns the regional access boundary lookup URL for the current client.
* This method is intended for internal use by the RegionalAccessBoundaryManager
* and should not be called directly by users.
*
* @return The regional access boundary URL string, or `null` if the client type
* does not support regional access boundaries.
* @throws {Error} If the URL cannot be constructed for a compatible client,
* for instance, if a required property like a service account email is missing.
* @internal
*/
public async getRegionalAccessBoundaryUrl(): Promise<string | null> {
return null;
}

/**
* Sets the auth credentials.
*/
setCredentials(credentials: Credentials) {
this.credentials = credentials;
}

/**
* Returns the current regional access boundary data.
* @internal
*/
getRegionalAccessBoundary(): RegionalAccessBoundaryData | null {
return this.regionalAccessBoundaryManager.data;
}

/**
* Returns the current regional access boundary cooldown time in milliseconds.
* @internal
*/
getRegionalAccessBoundaryCooldownTime(): number {
return this.regionalAccessBoundaryManager.cooldownTime;
}

/**
* Append additional headers, e.g., x-goog-user-project, shared across the
* classes inheriting AuthClient. This method should be used by any method
Expand All @@ -386,23 +428,47 @@ export abstract class AuthClient
) {
headers.set('x-goog-user-project', this.quotaProjectId);
}

return headers;
}

/**
* Adds the `x-goog-user-project` and `authorization` headers to the target Headers
* Applies regional access boundary rules to the provided headers.
* This includes adding the x-allowed-locations header and triggering
* a background refresh if needed.
* @param headers The headers to update.
* @param url Optional destination URL of the request. If missing, assumed global.
*/
protected applyRegionalAccessBoundary(
headers: Headers,
url?: string | URL,
): void {
const rabHeader =
this.regionalAccessBoundaryManager.getRegionalAccessBoundaryHeader(
url,
headers,
);
if (rabHeader) {
headers.set('x-allowed-locations', rabHeader);
}
}

/**
* Adds the `x-goog-user-project`, `authorization`, and 'x-allowed-locations'
* headers to the target Headers
* object, if they exist on the source.
*
* @param target the headers to target
* @param source the headers to source from
* @returns the target headers
*/
protected addUserProjectAndAuthHeaders<T extends Headers>(
protected applyHeadersFromSource<T extends Headers>(
target: T,
source: Headers,
): T {
const xGoogUserProject = source.get('x-goog-user-project');
const authorizationHeader = source.get('authorization');
const xGoogAllowedLocs = source.get('x-allowed-locations');

if (xGoogUserProject) {
target.set('x-goog-user-project', xGoogUserProject);
Expand All @@ -412,6 +478,10 @@ export abstract class AuthClient
target.set('authorization', authorizationHeader);
}

if (xGoogAllowedLocs) {
target.set('x-allowed-locations', xGoogAllowedLocs);
}

return target;
}

Expand Down Expand Up @@ -549,6 +619,20 @@ export abstract class AuthClient
},
};
}

/**
* Returns whether the provided credentials are expired or will expire within
* eagerRefreshThresholdMillismilliseconds.
* If there is no expiry time, assumes the token is not expired or expiring.
* @param credentials The credentials to check for expiration.
* @return Whether the credentials are expired or not.
*/
protected isExpired(credentials: Credentials = this.credentials): boolean {
const now = new Date().getTime();
return credentials.expiry_date
? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis
: false;
}
Comment on lines +630 to +635
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use optional chaining (credentials?.expiry_date) to prevent a potential runtime TypeError if credentials is null or undefined.

Suggested change
protected isExpired(credentials: Credentials = this.credentials): boolean {
const now = new Date().getTime();
return credentials.expiry_date
? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis
: false;
}
protected isExpired(credentials: Credentials = this.credentials): boolean {
const now = new Date().getTime();
return credentials?.expiry_date
? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis
: false;
}

}

// TypeScript does not have `HeadersInit` in the standard types yet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ import {
import * as sts from './stscredentials';
import {ClientAuthentication} from './oauth2common';
import {SnakeToCamelObject, originalOrCamelOptions} from '../util';
import {
getWorkforcePoolIdFromAudience,
getWorkloadPoolIdFromAudience,
} from '../util';
import {pkg} from '../shared.cjs';
import {
SERVICE_ACCOUNT_LOOKUP_ENDPOINT,
WORKFORCE_LOOKUP_ENDPOINT,
WORKLOAD_LOOKUP_ENDPOINT,
} from './regionalaccessboundary';

/**
* The required token exchange grant_type: rfc8693#section-2.1
Expand Down Expand Up @@ -415,11 +424,12 @@ 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.applyRegionalAccessBoundary(headers, url);
return this.addSharedMetadataHeaders(headers);
}

Expand Down Expand Up @@ -499,13 +509,14 @@ 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);
const requestHeaders = await this.getRequestHeaders(opts.url);
requestOpts.headers = Gaxios.mergeHeaders(requestOpts.headers);

this.addUserProjectAndAuthHeaders(opts.headers, requestHeaders);
this.applyHeadersFromSource(requestOpts.headers, requestHeaders);

response = await this.transporter.request<T>(opts);
response = await this.transporter.request<T>(requestOpts);
} catch (e) {
const res = (e as GaxiosError).response;
if (res) {
Expand Down Expand Up @@ -684,19 +695,6 @@ export abstract class BaseExternalAccountClient extends AuthClient {
};
}

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

/**
* @return The list of scopes for the requested GCP access token.
*/
Expand All @@ -722,4 +720,54 @@ export abstract class BaseExternalAccountClient extends AuthClient {
protected getTokenUrl(): string {
return this.tokenUrl;
}

/**
* Returns the regional access boundary lookup URL for the external account.
* This implementation constructs the URL based on the audience of the
* workforce or workload pool. If the client is configured for service account
* impersonation, it uses the target service account email to generate
* the lookup endpoint.
*
* @return The regional access boundary URL string.
* @internal
*/
public async getRegionalAccessBoundaryUrl(): Promise<string> {
if (this.serviceAccountImpersonationUrl) {
// When impersonating a service account, the regional access boundary is determined
// by the security policies of the target service account.
const email = this.getServiceAccountEmail();
if (!email) {
throw new Error(
`RegionalAccessBoundary: A service account email is required for regional access boundary lookups but could not be determined from the serviceAccountImpersonationUrl ${this.serviceAccountImpersonationUrl}.`,
);
}
return SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace(
'{service_account_email}',
encodeURIComponent(email),
);
}

// Check if the audience corresponds to a workload identity pool.
const wfPoolId = getWorkforcePoolIdFromAudience(this.audience);
if (wfPoolId) {
return WORKFORCE_LOOKUP_ENDPOINT.replace(
'{pool_id}',
encodeURIComponent(wfPoolId),
);
}

// Check if the audience corresponds to a workforce identity pool.
const wlPoolId = getWorkloadPoolIdFromAudience(this.audience);
const projectNumber = this.getProjectNumber(this.audience);
if (wlPoolId && projectNumber) {
return WORKLOAD_LOOKUP_ENDPOINT.replace(
'{project_id}',
projectNumber,
).replace('{pool_id}', wlPoolId);
}
Comment on lines +750 to +767
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comments for workforce and workload identity pools are swapped. wfPoolId corresponds to the workforce identity pool, and wlPoolId corresponds to the workload identity pool.

Suggested change
// Check if the audience corresponds to a workload identity pool.
const wfPoolId = getWorkforcePoolIdFromAudience(this.audience);
if (wfPoolId) {
return WORKFORCE_LOOKUP_ENDPOINT.replace(
'{pool_id}',
encodeURIComponent(wfPoolId),
);
}
// Check if the audience corresponds to a workforce identity pool.
const wlPoolId = getWorkloadPoolIdFromAudience(this.audience);
const projectNumber = this.getProjectNumber(this.audience);
if (wlPoolId && projectNumber) {
return WORKLOAD_LOOKUP_ENDPOINT.replace(
'{project_id}',
projectNumber,
).replace('{pool_id}', wlPoolId);
}
// Check if the audience corresponds to a workforce identity pool.
const wfPoolId = getWorkforcePoolIdFromAudience(this.audience);
if (wfPoolId) {
return WORKFORCE_LOOKUP_ENDPOINT.replace(
'{pool_id}',
encodeURIComponent(wfPoolId),
);
}
// Check if the audience corresponds to a workload identity pool.
const wlPoolId = getWorkloadPoolIdFromAudience(this.audience);
const projectNumber = this.getProjectNumber(this.audience);
if (wlPoolId && projectNumber) {
return WORKLOAD_LOOKUP_ENDPOINT.replace(
'{project_id}',
projectNumber,
).replace('{pool_id}', wlPoolId);
}


throw new RangeError(
`RegionalAccessBoundary: Invalid audience provided: "${this.audience}" does not correspond to a workforce or workload pool.`,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
OAuth2Client,
OAuth2ClientOptions,
} from './oauth2client';
import {SERVICE_ACCOUNT_LOOKUP_ENDPOINT} from './regionalaccessboundary';

export interface ComputeOptions extends OAuth2ClientOptions {
/**
Expand Down Expand Up @@ -137,4 +138,45 @@ export class Compute extends OAuth2Client {
}
}
}

/**
* Returns the regional access boundary lookup URL for the GCE instance.
* This implementation resolves the default service account email of the GCE
* instance to construct the lookup endpoint.
*
* @return The regional access boundary URL string.
* @internal
*/
public async getRegionalAccessBoundaryUrl(): Promise<string> {
const email = await this.resolveServiceAccountEmail();
const regionalAccessBoundaryUrl = SERVICE_ACCOUNT_LOOKUP_ENDPOINT.replace(
'{service_account_email}',
encodeURIComponent(email),
);
return regionalAccessBoundaryUrl;
}

/**
* Resolves the service account email. If the email is set to 'default',
* it fetches the email from the GCE metadata server.
* @returns A promise that resolves with the service account email.
*/
private async resolveServiceAccountEmail(): Promise<string> {
if (this.serviceAccountEmail !== 'default') {
// If a specific email is provided, return it directly.
return this.serviceAccountEmail;
}
Comment on lines +164 to +168
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If this.serviceAccountEmail is undefined, this.serviceAccountEmail !== 'default' evaluates to true, which returns undefined and leads to a malformed URL containing 'undefined'. Checking if this.serviceAccountEmail is truthy ensures it falls back to the metadata server as expected.

Suggested change
private async resolveServiceAccountEmail(): Promise<string> {
if (this.serviceAccountEmail !== 'default') {
// If a specific email is provided, return it directly.
return this.serviceAccountEmail;
}
private async resolveServiceAccountEmail(): Promise<string> {
if (this.serviceAccountEmail && this.serviceAccountEmail !== 'default') {
// If a specific email is provided, return it directly.
return this.serviceAccountEmail;
}


// Otherwise, fetch the default email from the metadata server.
try {
return await gcpMetadata.instance('service-accounts/default/email');
} catch (e) {
throw new Error(
'RegionalAccessBoundary: Failed to retrieve default service account email from metadata server.',
{
cause: e,
},
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export class DownscopedClient extends AuthClient {
const requestHeaders = await this.getRequestHeaders();
opts.headers = Gaxios.mergeHeaders(opts.headers);

this.addUserProjectAndAuthHeaders(opts.headers, requestHeaders);
this.applyHeadersFromSource(opts.headers, requestHeaders);

response = await this.transporter.request<T>(opts);
} catch (e) {
Expand Down Expand Up @@ -381,18 +381,4 @@ export class DownscopedClient extends AuthClient {
// Return the cached access token.
return this.cachedDownscopedAccessToken;
}

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