Skip to content

Commit 902bf8b

Browse files
authored
feat: Impersonated Universe Domain Support (#1875)
* feat: Impersonated w/ Universe Support * docs: jsdoc/tsdoc fix * feat: `useEmailAzp` * chore: compodoc nonsense * chore: for compodoc nonsense * chore: typo * refactor: Explicit Universe Domains should throw for `Impersonated` * feat: Support `external_account` in `fromImpersonatedJSON` * feat: Improve `Impersonated` Support
1 parent a65d8a1 commit 902bf8b

File tree

6 files changed

+206
-16
lines changed

6 files changed

+206
-16
lines changed

src/auth/authclient.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ interface AuthJSONOptions {
5555

5656
/**
5757
* The default service domain for a given Cloud universe.
58+
*
59+
* @example
60+
* 'googleapis.com'
5861
*/
5962
universe_domain: string;
6063

src/auth/googleauth.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
290290
}
291291
}
292292

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

635-
// Create source client for impersonation
636-
const sourceClient = new UserRefreshClient();
637-
sourceClient.fromJSON(json.source_credentials);
635+
const sourceClient = this.fromJSON(json.source_credentials);
638636

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

649-
// Extreact service account from service_account_impersonation_url
650-
const targetPrincipal = /(?<target>[^/]+):generateAccessToken$/.exec(
651-
json.service_account_impersonation_url
652-
)?.groups?.target;
647+
// Extract service account from service_account_impersonation_url
648+
const targetPrincipal =
649+
/(?<target>[^/]+):(generateAccessToken|generateIdToken)$/.exec(
650+
json.service_account_impersonation_url
651+
)?.groups?.target;
653652

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

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

662-
const client = new Impersonated({
661+
return new Impersonated({
663662
...json,
664-
delegates: json.delegates ?? [],
665-
sourceClient: sourceClient,
666-
targetPrincipal: targetPrincipal,
663+
sourceClient,
664+
targetPrincipal,
667665
targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes],
668666
});
669-
return client;
670667
}
671668

672669
/**
673670
* Create a credentials instance using the given input options.
671+
* This client is not cached.
672+
*
674673
* @param json The input object.
675674
* @param options The JWT or UserRefresh options for the client
676675
* @returns JWT or UserRefresh Client with data

src/auth/impersonated.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {AuthClient} from './authclient';
2323
import {IdTokenProvider} from './idtokenclient';
2424
import {GaxiosError} from 'gaxios';
2525
import {SignBlobResponse} from './googleauth';
26+
import {originalOrCamelOptions} from '../util';
2627

2728
export interface ImpersonatedOptions extends OAuth2ClientOptions {
2829
/**
@@ -124,15 +125,31 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider {
124125
this.delegates = options.delegates ?? [];
125126
this.targetScopes = options.targetScopes ?? [];
126127
this.lifetime = options.lifetime ?? 3600;
127-
this.endpoint = options.endpoint ?? 'https://iamcredentials.googleapis.com';
128+
129+
const usingExplicitUniverseDomain =
130+
!!originalOrCamelOptions(options).get('universe_domain');
131+
132+
if (!usingExplicitUniverseDomain) {
133+
// override the default universe with the source's universe
134+
this.universeDomain = this.sourceClient.universeDomain;
135+
} else if (this.sourceClient.universeDomain !== this.universeDomain) {
136+
// non-default universe and is not matching the source - this could be a credential leak
137+
throw new RangeError(
138+
`Universe domain ${this.sourceClient.universeDomain} in source credentials does not match ${this.universeDomain} universe domain set for impersonated credentials.`
139+
);
140+
}
141+
142+
this.endpoint =
143+
options.endpoint ?? `https://iamcredentials.${this.universeDomain}`;
128144
}
129145

130146
/**
131147
* Signs some bytes.
132148
*
133149
* {@link https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob Reference Documentation}
134150
* @param blobToSign String to sign.
135-
* @return <SignBlobResponse> denoting the keyyID and signedBlob in base64 string
151+
*
152+
* @returns A {@link SignBlobResponse} denoting the keyID and signedBlob in base64 string
136153
*/
137154
async sign(blobToSign: string): Promise<SignBlobResponse> {
138155
await this.sourceClient.getAccessToken();
@@ -224,7 +241,9 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider {
224241
delegates: this.delegates,
225242
audience: targetAudience,
226243
includeEmail: options?.includeEmail ?? true,
244+
useEmailAzp: options?.includeEmail ?? true,
227245
};
246+
228247
const res = await this.sourceClient.request<FetchIdTokenResponse>({
229248
...Impersonated.RETRY_CONFIG,
230249
url: u,

src/auth/refreshclient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,15 @@ export class UserRefreshClient extends OAuth2Client {
183183
});
184184
});
185185
}
186+
187+
/**
188+
* Create a UserRefreshClient credentials instance using the given input
189+
* options.
190+
* @param json The input object.
191+
*/
192+
static fromJSON(json: JWTInput): UserRefreshClient {
193+
const client = new UserRefreshClient();
194+
client.fromJSON(json);
195+
return client;
196+
}
186197
}

test/test.googleauth.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
ExternalAccountClientOptions,
4242
RefreshOptions,
4343
Impersonated,
44+
IdentityPoolClient,
4445
} from '../src';
4546
import {CredentialBody} from '../src/auth/credentials';
4647
import * as envDetect from '../src/auth/envDetect';
@@ -52,11 +53,16 @@ import {
5253
mockStsTokenExchange,
5354
saEmail,
5455
} from './externalclienthelper';
55-
import {BaseExternalAccountClient} from '../src/auth/baseexternalclient';
56+
import {
57+
BaseExternalAccountClient,
58+
EXTERNAL_ACCOUNT_TYPE,
59+
} from '../src/auth/baseexternalclient';
5660
import {AuthClient, DEFAULT_UNIVERSE} from '../src/auth/authclient';
5761
import {ExternalAccountAuthorizedUserClient} from '../src/auth/externalAccountAuthorizedUserClient';
5862
import {stringify} from 'querystring';
5963
import {GoogleAuthExceptionMessages} from '../src/auth/googleauth';
64+
import {IMPERSONATED_ACCOUNT_TYPE} from '../src/auth/impersonated';
65+
import {USER_REFRESH_ACCOUNT_TYPE} from '../src/auth/refreshclient';
6066

6167
nock.disableNetConnect();
6268

@@ -1656,6 +1662,86 @@ describe('googleauth', () => {
16561662
.reply(200, {});
16571663
}
16581664
describe('for impersonated types', () => {
1665+
describe('source clients', () => {
1666+
it('should support a variety of source clients', async () => {
1667+
const serviceAccountImpersonationURLBase =
1668+
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateToken';
1669+
const samples: {
1670+
creds: {
1671+
type: typeof IMPERSONATED_ACCOUNT_TYPE;
1672+
service_account_impersonation_url: string;
1673+
source_credentials: {};
1674+
};
1675+
expectedSource: typeof AuthClient;
1676+
}[] = [
1677+
// USER_TO_SERVICE_ACCOUNT_JSON
1678+
{
1679+
creds: {
1680+
type: IMPERSONATED_ACCOUNT_TYPE,
1681+
service_account_impersonation_url: new URL(
1682+
'./[email protected]:generateAccessToken',
1683+
serviceAccountImpersonationURLBase
1684+
).toString(),
1685+
source_credentials: {
1686+
client_id: 'client',
1687+
client_secret: 'secret',
1688+
refresh_token: 'refreshToken',
1689+
type: USER_REFRESH_ACCOUNT_TYPE,
1690+
},
1691+
},
1692+
expectedSource: UserRefreshClient,
1693+
},
1694+
// SERVICE_ACCOUNT_TO_SERVICE_ACCOUNT_JSON
1695+
{
1696+
creds: {
1697+
type: IMPERSONATED_ACCOUNT_TYPE,
1698+
service_account_impersonation_url: new URL(
1699+
'./[email protected]:generateIdToken',
1700+
serviceAccountImpersonationURLBase
1701+
).toString(),
1702+
source_credentials: {
1703+
type: 'service_account',
1704+
client_email: '[email protected]',
1705+
private_key: privateKey,
1706+
},
1707+
},
1708+
expectedSource: JWT,
1709+
},
1710+
// EXTERNAL_ACCOUNT_TO_SERVICE_ACCOUNT_JSON
1711+
{
1712+
creds: {
1713+
type: IMPERSONATED_ACCOUNT_TYPE,
1714+
service_account_impersonation_url: new URL(
1715+
'./[email protected]:generateIdToken',
1716+
serviceAccountImpersonationURLBase
1717+
).toString(),
1718+
source_credentials: {
1719+
type: EXTERNAL_ACCOUNT_TYPE,
1720+
audience: 'audience',
1721+
subject_token_type: 'access_token',
1722+
token_url: 'https://sts.googleapis.com/v1/token',
1723+
credential_source: {url: 'https://example.com/token'},
1724+
},
1725+
},
1726+
expectedSource: IdentityPoolClient,
1727+
},
1728+
];
1729+
1730+
const auth = new GoogleAuth();
1731+
for (const {creds, expectedSource} of samples) {
1732+
const client = auth.fromJSON(creds);
1733+
1734+
assert(client instanceof Impersonated);
1735+
1736+
// This is a private prop - we will refactor/remove in the future
1737+
assert(
1738+
(client as unknown as {sourceClient: {}}).sourceClient instanceof
1739+
expectedSource
1740+
);
1741+
}
1742+
});
1743+
});
1744+
16591745
describe('for impersonated credentials signing', () => {
16601746
const now = new Date().getTime();
16611747
const saSuccessResponse = {

test/test.impersonated.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,76 @@ describe('impersonated', () => {
9797
scopes.forEach(s => s.done());
9898
});
9999

100+
it('should inherit a `universeDomain` from the source client', async () => {
101+
const universeDomain = 'my.universe.com';
102+
103+
const tomorrow = new Date();
104+
tomorrow.setDate(tomorrow.getDate() + 1);
105+
106+
const scopes = [
107+
nock(url).get('/').reply(200),
108+
createGTokenMock({
109+
access_token: 'abc123',
110+
}),
111+
nock(`https://iamcredentials.${universeDomain}`)
112+
.post(
113+
'/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken',
114+
(body: ImpersonatedCredentialRequest) => {
115+
assert.strictEqual(body.lifetime, '30s');
116+
assert.deepStrictEqual(body.delegates, []);
117+
assert.deepStrictEqual(body.scope, [
118+
'https://www.googleapis.com/auth/cloud-platform',
119+
]);
120+
return true;
121+
}
122+
)
123+
.reply(200, {
124+
accessToken: 'universe-token',
125+
expireTime: tomorrow.toISOString(),
126+
}),
127+
];
128+
129+
const sourceClient = createSampleJWTClient();
130+
131+
// Use a simple API key for this test. No need to get too fancy.
132+
sourceClient.apiKey = 'ABC';
133+
delete sourceClient.subject;
134+
135+
sourceClient.universeDomain = universeDomain;
136+
137+
const impersonated = new Impersonated({
138+
sourceClient,
139+
targetPrincipal: '[email protected]',
140+
lifetime: 30,
141+
delegates: [],
142+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
143+
});
144+
145+
await impersonated.request({url});
146+
assert.strictEqual(impersonated.credentials.access_token, 'universe-token');
147+
148+
scopes.forEach(s => s.done());
149+
});
150+
151+
it("should throw if an explicit `universeDomain` does not equal the source's `universeDomain`", async () => {
152+
const universeDomain = 'my.universe.com';
153+
const otherUniverseDomain = 'not-my.universe.com';
154+
155+
const sourceClient = createSampleJWTClient();
156+
sourceClient.universeDomain = otherUniverseDomain;
157+
158+
assert.throws(() => {
159+
new Impersonated({
160+
sourceClient,
161+
targetPrincipal: '[email protected]',
162+
lifetime: 30,
163+
delegates: [],
164+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
165+
universeDomain,
166+
});
167+
}, /does not match/);
168+
});
169+
100170
it('should not request impersonated credentials on second request', async () => {
101171
const tomorrow = new Date();
102172
tomorrow.setDate(tomorrow.getDate() + 1);
@@ -383,10 +453,12 @@ describe('impersonated', () => {
383453
delegates: string[];
384454
audience: string;
385455
includeEmail: boolean;
456+
useEmailAzp: true;
386457
}) => {
387458
assert.strictEqual(body.audience, expectedAudience);
388459
assert.strictEqual(body.includeEmail, expectedIncludeEmail);
389460
assert.deepStrictEqual(body.delegates, expectedDeligates);
461+
assert.strictEqual(body.useEmailAzp, true);
390462
return true;
391463
}
392464
)

0 commit comments

Comments
 (0)