Skip to content

Commit 835be89

Browse files
feat: handle impersonated ADC (#1425)
* feat: handle impersonated ADC * fix: linting of fromImpersonatedADC * test: add test for impersonated ACD * doc: add impersonated ADC capabilities to readme * fix: resolve code review for impersonated ADC * fix: resolve targetScopes typing for impersonated ADC * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent 672818b commit 835be89

6 files changed

+177
-4
lines changed

src/auth/credentials.ts

+7
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export interface JWTInput {
7878
quota_project_id?: string;
7979
}
8080

81+
export interface ImpersonatedJWTInput {
82+
type?: string;
83+
source_credentials?: JWTInput;
84+
service_account_impersonation_url?: string;
85+
delegates?: string[];
86+
}
87+
8188
export interface CredentialBody {
8289
client_email?: string;
8390
private_key?: string;

src/auth/googleauth.ts

+70-3
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ import {Crypto, createCrypto} from '../crypto/crypto';
2424
import {DefaultTransporter, Transporter} from '../transporters';
2525

2626
import {Compute, ComputeOptions} from './computeclient';
27-
import {CredentialBody, JWTInput} from './credentials';
27+
import {CredentialBody, ImpersonatedJWTInput, JWTInput} from './credentials';
2828
import {IdTokenClient} from './idtokenclient';
2929
import {GCPEnv, getEnv} from './envDetect';
3030
import {JWT, JWTOptions} from './jwtclient';
3131
import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client';
3232
import {UserRefreshClient, UserRefreshClientOptions} from './refreshclient';
33-
import {Impersonated, ImpersonatedOptions} from './impersonated';
33+
import {
34+
Impersonated,
35+
ImpersonatedOptions,
36+
IMPERSONATED_ACCOUNT_TYPE,
37+
} from './impersonated';
3438
import {
3539
ExternalAccountClient,
3640
ExternalAccountClientOptions,
@@ -459,13 +463,72 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
459463
return this.fromStream(readStream, options);
460464
}
461465

466+
/**
467+
* Create a credentials instance using a given impersonated input options.
468+
* @param json The impersonated input object.
469+
* @returns JWT or UserRefresh Client with data
470+
*/
471+
fromImpersonatedJSON(json: ImpersonatedJWTInput): Impersonated {
472+
if (!json) {
473+
throw new Error(
474+
'Must pass in a JSON object containing an impersonated refresh token'
475+
);
476+
}
477+
if (json.type !== IMPERSONATED_ACCOUNT_TYPE) {
478+
throw new Error(
479+
`The incoming JSON object does not have the "${IMPERSONATED_ACCOUNT_TYPE}" type`
480+
);
481+
}
482+
if (!json.source_credentials) {
483+
throw new Error(
484+
'The incoming JSON object does not contain a source_credentials field'
485+
);
486+
}
487+
if (!json.service_account_impersonation_url) {
488+
throw new Error(
489+
'The incoming JSON object does not contain a service_account_impersonation_url field'
490+
);
491+
}
492+
493+
// Create source client for impersonation
494+
const sourceClient = new UserRefreshClient(
495+
json.source_credentials.client_id,
496+
json.source_credentials.client_secret,
497+
json.source_credentials.refresh_token
498+
);
499+
500+
// Extreact service account from service_account_impersonation_url
501+
const targetPrincipal = /(?<target>[^/]+):generateAccessToken$/.exec(
502+
json.service_account_impersonation_url
503+
)?.groups?.target;
504+
505+
if (!targetPrincipal) {
506+
throw new RangeError(
507+
`Cannot extract target principal from ${json.service_account_impersonation_url}`
508+
);
509+
}
510+
511+
const targetScopes = this.getAnyScopes() ?? [];
512+
513+
const client = new Impersonated({
514+
delegates: json.delegates ?? [],
515+
sourceClient: sourceClient,
516+
targetPrincipal: targetPrincipal,
517+
targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes],
518+
});
519+
return client;
520+
}
521+
462522
/**
463523
* Create a credentials instance using the given input options.
464524
* @param json The input object.
465525
* @param options The JWT or UserRefresh options for the client
466526
* @returns JWT or UserRefresh Client with data
467527
*/
468-
fromJSON(json: JWTInput, options?: RefreshOptions): JSONClient {
528+
fromJSON(
529+
json: JWTInput | ImpersonatedJWTInput,
530+
options?: RefreshOptions
531+
): JSONClient {
469532
let client: JSONClient;
470533
if (!json) {
471534
throw new Error(
@@ -476,6 +539,8 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
476539
if (json.type === 'authorized_user') {
477540
client = new UserRefreshClient(options);
478541
client.fromJSON(json);
542+
} else if (json.type === IMPERSONATED_ACCOUNT_TYPE) {
543+
client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput);
479544
} else if (json.type === EXTERNAL_ACCOUNT_TYPE) {
480545
client = ExternalAccountClient.fromJSON(
481546
json as ExternalAccountClientOptions,
@@ -508,6 +573,8 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
508573
if (json.type === 'authorized_user') {
509574
client = new UserRefreshClient(options);
510575
client.fromJSON(json);
576+
} else if (json.type === IMPERSONATED_ACCOUNT_TYPE) {
577+
client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput);
511578
} else if (json.type === EXTERNAL_ACCOUNT_TYPE) {
512579
client = ExternalAccountClient.fromJSON(
513580
json as ExternalAccountClientOptions,

src/auth/impersonated.ts

+2
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface ImpersonatedOptions extends RefreshOptions {
4545
endpoint?: string;
4646
}
4747

48+
export const IMPERSONATED_ACCOUNT_TYPE = 'impersonated_service_account';
49+
4850
export interface TokenResponse {
4951
accessToken: string;
5052
expireTime: string;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"delegates": [],
3+
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
4+
"source_credentials": {
5+
"client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
6+
"client_secret": "privatekey",
7+
"refresh_token": "refreshtoken",
8+
"type": "authorized_user"
9+
},
10+
"type": "impersonated_service_account"
11+
}

test/test.googleauth.ts

+39
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
OAuth2Client,
4040
ExternalAccountClientOptions,
4141
RefreshOptions,
42+
Impersonated,
4243
} from '../src';
4344
import {CredentialBody} from '../src/auth/credentials';
4445
import * as envDetect from '../src/auth/envDetect';
@@ -2183,6 +2184,44 @@ describe('googleauth', () => {
21832184
scopes.forEach(s => s.done());
21842185
});
21852186

2187+
it('should initialize from impersonated ADC', async () => {
2188+
// Set up a mock to return path to a valid credentials file.
2189+
mockEnvVar(
2190+
'GOOGLE_APPLICATION_CREDENTIALS',
2191+
'./test/fixtures/impersonated_application_default_credentials.json'
2192+
);
2193+
2194+
// Set up a mock to explicity return the Project ID, as needed for impersonated ADC
2195+
mockEnvVar('GCLOUD_PROJECT', STUB_PROJECT);
2196+
2197+
const auth = new GoogleAuth();
2198+
const client = await auth.getClient();
2199+
2200+
assert(client instanceof Impersonated);
2201+
2202+
// Check if targetPrincipal gets extracted and used correctly
2203+
const tomorrow = new Date();
2204+
tomorrow.setDate(tomorrow.getDate() + 1);
2205+
2206+
const scopes = [
2207+
nock('https://oauth2.googleapis.com').post('/token').reply(200, {
2208+
access_token: 'abc123',
2209+
}),
2210+
nock('https://iamcredentials.googleapis.com')
2211+
.post(
2212+
'/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken'
2213+
)
2214+
.reply(200, {
2215+
accessToken: 'qwerty345',
2216+
expireTime: tomorrow.toISOString(),
2217+
}),
2218+
];
2219+
2220+
await client.refreshAccessToken();
2221+
scopes.forEach(s => s.done());
2222+
assert.strictEqual(client.credentials.access_token, 'qwerty345');
2223+
});
2224+
21862225
it('should allow use defaultScopes when no scopes are available', async () => {
21872226
const keyFilename = './test/fixtures/external-account-cred.json';
21882227
const auth = new GoogleAuth({keyFilename});

test/test.impersonated.ts

+48-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import * as assert from 'assert';
1818
import * as nock from 'nock';
1919
import {describe, it, afterEach} from 'mocha';
20-
import {Impersonated, JWT} from '../src';
20+
import {Impersonated, JWT, UserRefreshClient} from '../src';
2121
import {CredentialRequest} from '../src/auth/credentials';
2222

2323
const PEM_PATH = './test/fixtures/private.pem';
@@ -204,6 +204,53 @@ describe('impersonated', () => {
204204
scopes.forEach(s => s.done());
205205
});
206206

207+
it('handles authenticating with UserRefreshClient as sourceClient', async () => {
208+
const tomorrow = new Date();
209+
tomorrow.setDate(tomorrow.getDate() + 1);
210+
const scopes = [
211+
nock(url).get('/').reply(200),
212+
nock('https://oauth2.googleapis.com').post('/token').reply(200, {
213+
access_token: 'abc123',
214+
}),
215+
nock('https://iamcredentials.googleapis.com')
216+
.post(
217+
'/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken',
218+
(body: ImpersonatedCredentialRequest) => {
219+
assert.strictEqual(body.lifetime, '30s');
220+
assert.deepStrictEqual(body.delegates, []);
221+
assert.deepStrictEqual(body.scope, [
222+
'https://www.googleapis.com/auth/cloud-platform',
223+
]);
224+
return true;
225+
}
226+
)
227+
.reply(200, {
228+
accessToken: 'qwerty345',
229+
expireTime: tomorrow.toISOString(),
230+
}),
231+
];
232+
233+
const source_client = new UserRefreshClient(
234+
'CLIENT_ID',
235+
'CLIENT_SECRET',
236+
'REFRESH_TOKEN'
237+
);
238+
const impersonated = new Impersonated({
239+
sourceClient: source_client,
240+
targetPrincipal: '[email protected]',
241+
lifetime: 30,
242+
delegates: [],
243+
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
244+
});
245+
await impersonated.request({url});
246+
assert.strictEqual(impersonated.credentials.access_token, 'qwerty345');
247+
assert.strictEqual(
248+
impersonated.credentials.expiry_date,
249+
tomorrow.getTime()
250+
);
251+
scopes.forEach(s => s.done());
252+
});
253+
207254
it('throws meaningful error when context available', async () => {
208255
const tomorrow = new Date();
209256
tomorrow.setDate(tomorrow.getDate() + 1);

0 commit comments

Comments
 (0)