Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Impersonated Universe Domain Support #1875

Merged
merged 13 commits into from
Nov 1, 2024
Merged
3 changes: 3 additions & 0 deletions src/auth/authclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ interface AuthJSONOptions {

/**
* The default service domain for a given Cloud universe.
*
* @example
* 'googleapis.com'
*/
universe_domain: string;

Expand Down
25 changes: 12 additions & 13 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
}
}

/*
/**
* A private method for finding and caching a projectId.
*
* Supports environments in order of precedence:
Expand Down Expand Up @@ -632,9 +632,7 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
);
}

// Create source client for impersonation
const sourceClient = new UserRefreshClient();
sourceClient.fromJSON(json.source_credentials);
const sourceClient = this.fromJSON(json.source_credentials);
Copy link
Contributor

Choose a reason for hiding this comment

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

This change is intentional to allow for impersonating any arbitrary type of source clients instead of just UserRefreshClient() right? Just for my own understanding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep! Other clients are able to use impersonation and its required in TPC (e.g. for external accounts)

Copy link
Contributor

Choose a reason for hiding this comment

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

Gotcha, only note I have is that the impersonation is handled directly by the external account client itself in this library (this already works for TPC, the inmpersonation URL gets generated with the universe domain when the create-credential-config is run in gCloud): https://github.com/googleapis/google-auth-library-nodejs/blob/a65d8a11450fdc0f69ea228def462e5a77beecd5/src/auth/baseexternalclient.ts#L602C4-L605C9

This PR adds support for a different type of credential configuration file which we should support, but just wanted to point out I don't think this is a path we would expect people to normally use for BYOID creds.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks! Important call-out.


if (json.service_account_impersonation_url?.length > 256) {
/**
Expand All @@ -646,10 +644,11 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
);
}

// Extreact service account from service_account_impersonation_url
const targetPrincipal = /(?<target>[^/]+):generateAccessToken$/.exec(
json.service_account_impersonation_url
)?.groups?.target;
// Extract service account from service_account_impersonation_url
const targetPrincipal =
/(?<target>[^/]+):(generateAccessToken|generateIdToken)$/.exec(
json.service_account_impersonation_url
)?.groups?.target;

if (!targetPrincipal) {
throw new RangeError(
Expand All @@ -659,18 +658,18 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {

const targetScopes = this.getAnyScopes() ?? [];

const client = new Impersonated({
return new Impersonated({
...json,
delegates: json.delegates ?? [],
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
sourceClient: sourceClient,
targetPrincipal: targetPrincipal,
sourceClient,
targetPrincipal,
targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes],
});
return client;
}

/**
* Create a credentials instance using the given input options.
* This client is not cached.
*
* @param json The input object.
* @param options The JWT or UserRefresh options for the client
* @returns JWT or UserRefresh Client with data
Expand Down
23 changes: 21 additions & 2 deletions src/auth/impersonated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {AuthClient} from './authclient';
import {IdTokenProvider} from './idtokenclient';
import {GaxiosError} from 'gaxios';
import {SignBlobResponse} from './googleauth';
import {originalOrCamelOptions} from '../util';

export interface ImpersonatedOptions extends OAuth2ClientOptions {
/**
Expand Down Expand Up @@ -124,15 +125,31 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider {
this.delegates = options.delegates ?? [];
this.targetScopes = options.targetScopes ?? [];
this.lifetime = options.lifetime ?? 3600;
this.endpoint = options.endpoint ?? 'https://iamcredentials.googleapis.com';

const usingExplicitUniverseDomain =
!!originalOrCamelOptions(options).get('universe_domain');

if (!usingExplicitUniverseDomain) {
// override the default universe with the source's universe
this.universeDomain = this.sourceClient.universeDomain;
} else if (this.sourceClient.universeDomain !== this.universeDomain) {
// non-default universe and is not matching the source - this could be a credential leak
throw new RangeError(
`Universe domain ${this.sourceClient.universeDomain} in source credentials does not match ${this.universeDomain} universe domain set for impersonated credentials.`
);
}

this.endpoint =
options.endpoint ?? `https://iamcredentials.${this.universeDomain}`;
}

/**
* Signs some bytes.
*
* {@link https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob Reference Documentation}
* @param blobToSign String to sign.
* @return <SignBlobResponse> denoting the keyyID and signedBlob in base64 string
*
* @returns A {@link SignBlobResponse} denoting the keyID and signedBlob in base64 string
*/
async sign(blobToSign: string): Promise<SignBlobResponse> {
await this.sourceClient.getAccessToken();
Expand Down Expand Up @@ -224,7 +241,9 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider {
delegates: this.delegates,
audience: targetAudience,
includeEmail: options?.includeEmail ?? true,
useEmailAzp: options?.includeEmail ?? true,
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
};

const res = await this.sourceClient.request<FetchIdTokenResponse>({
...Impersonated.RETRY_CONFIG,
url: u,
Expand Down
11 changes: 11 additions & 0 deletions src/auth/refreshclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,15 @@ export class UserRefreshClient extends OAuth2Client {
});
});
}

/**
* Create a UserRefreshClient credentials instance using the given input
* options.
* @param json The input object.
*/
static fromJSON(json: JWTInput): UserRefreshClient {
const client = new UserRefreshClient();
client.fromJSON(json);
return client;
}
}
88 changes: 87 additions & 1 deletion test/test.googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
ExternalAccountClientOptions,
RefreshOptions,
Impersonated,
IdentityPoolClient,
} from '../src';
import {CredentialBody} from '../src/auth/credentials';
import * as envDetect from '../src/auth/envDetect';
Expand All @@ -52,11 +53,16 @@ import {
mockStsTokenExchange,
saEmail,
} from './externalclienthelper';
import {BaseExternalAccountClient} from '../src/auth/baseexternalclient';
import {
BaseExternalAccountClient,
EXTERNAL_ACCOUNT_TYPE,
} from '../src/auth/baseexternalclient';
import {AuthClient, DEFAULT_UNIVERSE} from '../src/auth/authclient';
import {ExternalAccountAuthorizedUserClient} from '../src/auth/externalAccountAuthorizedUserClient';
import {stringify} from 'querystring';
import {GoogleAuthExceptionMessages} from '../src/auth/googleauth';
import {IMPERSONATED_ACCOUNT_TYPE} from '../src/auth/impersonated';
import {USER_REFRESH_ACCOUNT_TYPE} from '../src/auth/refreshclient';

nock.disableNetConnect();

Expand Down Expand Up @@ -1656,6 +1662,86 @@ describe('googleauth', () => {
.reply(200, {});
}
describe('for impersonated types', () => {
describe('source clients', () => {
it('should support a variety of source clients', async () => {
const serviceAccountImpersonationURLBase =
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateToken';
const samples: {
creds: {
type: typeof IMPERSONATED_ACCOUNT_TYPE;
service_account_impersonation_url: string;
source_credentials: {};
};
expectedSource: typeof AuthClient;
}[] = [
// USER_TO_SERVICE_ACCOUNT_JSON
{
creds: {
type: IMPERSONATED_ACCOUNT_TYPE,
service_account_impersonation_url: new URL(
'./[email protected]:generateAccessToken',
serviceAccountImpersonationURLBase
).toString(),
source_credentials: {
client_id: 'client',
client_secret: 'secret',
refresh_token: 'refreshToken',
type: USER_REFRESH_ACCOUNT_TYPE,
},
},
expectedSource: UserRefreshClient,
},
// SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON
{
creds: {
type: IMPERSONATED_ACCOUNT_TYPE,
service_account_impersonation_url: new URL(
'./[email protected]:generateIdToken',
serviceAccountImpersonationURLBase
).toString(),
source_credentials: {
type: 'service_account',
client_email: '[email protected]',
private_key: privateKey,
},
},
expectedSource: JWT,
},
// EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON
{
creds: {
type: IMPERSONATED_ACCOUNT_TYPE,
service_account_impersonation_url: new URL(
'./[email protected]:generateIdToken',
serviceAccountImpersonationURLBase
).toString(),
source_credentials: {
type: EXTERNAL_ACCOUNT_TYPE,
audience: 'audience',
subject_token_type: 'access_token',
token_url: 'https://sts.googleapis.com/v1/token',
credential_source: {url: 'https://example.com/token'},
},
},
expectedSource: IdentityPoolClient,
},
];

const auth = new GoogleAuth();
for (const {creds, expectedSource} of samples) {
const client = auth.fromJSON(creds);

assert(client instanceof Impersonated);

// This is a private prop - we will refactor/remove in the future
assert(
(client as unknown as {sourceClient: {}}).sourceClient instanceof
expectedSource
);
}
});
});

describe('for impersonated credentials signing', () => {
const now = new Date().getTime();
const saSuccessResponse = {
Expand Down
72 changes: 72 additions & 0 deletions test/test.impersonated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,76 @@ describe('impersonated', () => {
scopes.forEach(s => s.done());
});

it('should inherit a `universeDomain` from the source client', async () => {
const universeDomain = 'my.universe.com';

const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

const scopes = [
nock(url).get('/').reply(200),
createGTokenMock({
access_token: 'abc123',
}),
nock(`https://iamcredentials.${universeDomain}`)
.post(
'/v1/projects/-/serviceAccounts/[email protected]: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: 'universe-token',
expireTime: tomorrow.toISOString(),
}),
];

const sourceClient = createSampleJWTClient();

// Use a simple API key for this test. No need to get too fancy.
sourceClient.apiKey = 'ABC';
delete sourceClient.subject;

sourceClient.universeDomain = universeDomain;

const impersonated = new Impersonated({
sourceClient,
targetPrincipal: '[email protected]',
lifetime: 30,
delegates: [],
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
});

await impersonated.request({url});
assert.strictEqual(impersonated.credentials.access_token, 'universe-token');

scopes.forEach(s => s.done());
});

it("should throw if an explicit `universeDomain` does not equal the source's `universeDomain`", async () => {
const universeDomain = 'my.universe.com';
const otherUniverseDomain = 'not-my.universe.com';

const sourceClient = createSampleJWTClient();
sourceClient.universeDomain = otherUniverseDomain;

assert.throws(() => {
new Impersonated({
sourceClient,
targetPrincipal: '[email protected]',
lifetime: 30,
delegates: [],
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
universeDomain,
});
}, /does not match/);
});

it('should not request impersonated credentials on second request', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
Expand Down Expand Up @@ -383,10 +453,12 @@ describe('impersonated', () => {
delegates: string[];
audience: string;
includeEmail: boolean;
useEmailAzp: true;
}) => {
assert.strictEqual(body.audience, expectedAudience);
assert.strictEqual(body.includeEmail, expectedIncludeEmail);
assert.deepStrictEqual(body.delegates, expectedDeligates);
assert.strictEqual(body.useEmailAzp, true);
return true;
}
)
Expand Down
Loading