Skip to content

Commit

Permalink
feat: handle impersonated ADC (#1425)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
cstanger and gcf-owl-bot[bot] authored Jun 30, 2022
1 parent 672818b commit 835be89
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 4 deletions.
7 changes: 7 additions & 0 deletions src/auth/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ export interface JWTInput {
quota_project_id?: string;
}

export interface ImpersonatedJWTInput {
type?: string;
source_credentials?: JWTInput;
service_account_impersonation_url?: string;
delegates?: string[];
}

export interface CredentialBody {
client_email?: string;
private_key?: string;
Expand Down
73 changes: 70 additions & 3 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import {Crypto, createCrypto} from '../crypto/crypto';
import {DefaultTransporter, Transporter} from '../transporters';

import {Compute, ComputeOptions} from './computeclient';
import {CredentialBody, JWTInput} from './credentials';
import {CredentialBody, ImpersonatedJWTInput, JWTInput} from './credentials';
import {IdTokenClient} from './idtokenclient';
import {GCPEnv, getEnv} from './envDetect';
import {JWT, JWTOptions} from './jwtclient';
import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client';
import {UserRefreshClient, UserRefreshClientOptions} from './refreshclient';
import {Impersonated, ImpersonatedOptions} from './impersonated';
import {
Impersonated,
ImpersonatedOptions,
IMPERSONATED_ACCOUNT_TYPE,
} from './impersonated';
import {
ExternalAccountClient,
ExternalAccountClientOptions,
Expand Down Expand Up @@ -459,13 +463,72 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
return this.fromStream(readStream, options);
}

/**
* Create a credentials instance using a given impersonated input options.
* @param json The impersonated input object.
* @returns JWT or UserRefresh Client with data
*/
fromImpersonatedJSON(json: ImpersonatedJWTInput): Impersonated {
if (!json) {
throw new Error(
'Must pass in a JSON object containing an impersonated refresh token'
);
}
if (json.type !== IMPERSONATED_ACCOUNT_TYPE) {
throw new Error(
`The incoming JSON object does not have the "${IMPERSONATED_ACCOUNT_TYPE}" type`
);
}
if (!json.source_credentials) {
throw new Error(
'The incoming JSON object does not contain a source_credentials field'
);
}
if (!json.service_account_impersonation_url) {
throw new Error(
'The incoming JSON object does not contain a service_account_impersonation_url field'
);
}

// Create source client for impersonation
const sourceClient = new UserRefreshClient(
json.source_credentials.client_id,
json.source_credentials.client_secret,
json.source_credentials.refresh_token
);

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

if (!targetPrincipal) {
throw new RangeError(
`Cannot extract target principal from ${json.service_account_impersonation_url}`
);
}

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

const client = new Impersonated({
delegates: json.delegates ?? [],
sourceClient: sourceClient,
targetPrincipal: targetPrincipal,
targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes],
});
return client;
}

/**
* Create a credentials instance using the given input options.
* @param json The input object.
* @param options The JWT or UserRefresh options for the client
* @returns JWT or UserRefresh Client with data
*/
fromJSON(json: JWTInput, options?: RefreshOptions): JSONClient {
fromJSON(
json: JWTInput | ImpersonatedJWTInput,
options?: RefreshOptions
): JSONClient {
let client: JSONClient;
if (!json) {
throw new Error(
Expand All @@ -476,6 +539,8 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
if (json.type === 'authorized_user') {
client = new UserRefreshClient(options);
client.fromJSON(json);
} else if (json.type === IMPERSONATED_ACCOUNT_TYPE) {
client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput);
} else if (json.type === EXTERNAL_ACCOUNT_TYPE) {
client = ExternalAccountClient.fromJSON(
json as ExternalAccountClientOptions,
Expand Down Expand Up @@ -508,6 +573,8 @@ export class GoogleAuth<T extends AuthClient = JSONClient> {
if (json.type === 'authorized_user') {
client = new UserRefreshClient(options);
client.fromJSON(json);
} else if (json.type === IMPERSONATED_ACCOUNT_TYPE) {
client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput);
} else if (json.type === EXTERNAL_ACCOUNT_TYPE) {
client = ExternalAccountClient.fromJSON(
json as ExternalAccountClientOptions,
Expand Down
2 changes: 2 additions & 0 deletions src/auth/impersonated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface ImpersonatedOptions extends RefreshOptions {
endpoint?: string;
}

export const IMPERSONATED_ACCOUNT_TYPE = 'impersonated_service_account';

export interface TokenResponse {
accessToken: string;
expireTime: string;
Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/impersonated_application_default_credentials.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"delegates": [],
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
"source_credentials": {
"client_id": "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
"client_secret": "privatekey",
"refresh_token": "refreshtoken",
"type": "authorized_user"
},
"type": "impersonated_service_account"
}
39 changes: 39 additions & 0 deletions test/test.googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
OAuth2Client,
ExternalAccountClientOptions,
RefreshOptions,
Impersonated,
} from '../src';
import {CredentialBody} from '../src/auth/credentials';
import * as envDetect from '../src/auth/envDetect';
Expand Down Expand Up @@ -2183,6 +2184,44 @@ describe('googleauth', () => {
scopes.forEach(s => s.done());
});

it('should initialize from impersonated ADC', async () => {
// Set up a mock to return path to a valid credentials file.
mockEnvVar(
'GOOGLE_APPLICATION_CREDENTIALS',
'./test/fixtures/impersonated_application_default_credentials.json'
);

// Set up a mock to explicity return the Project ID, as needed for impersonated ADC
mockEnvVar('GCLOUD_PROJECT', STUB_PROJECT);

const auth = new GoogleAuth();
const client = await auth.getClient();

assert(client instanceof Impersonated);

// Check if targetPrincipal gets extracted and used correctly
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

const scopes = [
nock('https://oauth2.googleapis.com').post('/token').reply(200, {
access_token: 'abc123',
}),
nock('https://iamcredentials.googleapis.com')
.post(
'/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken'
)
.reply(200, {
accessToken: 'qwerty345',
expireTime: tomorrow.toISOString(),
}),
];

await client.refreshAccessToken();
scopes.forEach(s => s.done());
assert.strictEqual(client.credentials.access_token, 'qwerty345');
});

it('should allow use defaultScopes when no scopes are available', async () => {
const keyFilename = './test/fixtures/external-account-cred.json';
const auth = new GoogleAuth({keyFilename});
Expand Down
49 changes: 48 additions & 1 deletion test/test.impersonated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import * as assert from 'assert';
import * as nock from 'nock';
import {describe, it, afterEach} from 'mocha';
import {Impersonated, JWT} from '../src';
import {Impersonated, JWT, UserRefreshClient} from '../src';
import {CredentialRequest} from '../src/auth/credentials';

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

it('handles authenticating with UserRefreshClient as sourceClient', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const scopes = [
nock(url).get('/').reply(200),
nock('https://oauth2.googleapis.com').post('/token').reply(200, {
access_token: 'abc123',
}),
nock('https://iamcredentials.googleapis.com')
.post(
'/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com: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: 'qwerty345',
expireTime: tomorrow.toISOString(),
}),
];

const source_client = new UserRefreshClient(
'CLIENT_ID',
'CLIENT_SECRET',
'REFRESH_TOKEN'
);
const impersonated = new Impersonated({
sourceClient: source_client,
targetPrincipal: 'target@project.iam.gserviceaccount.com',
lifetime: 30,
delegates: [],
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
await impersonated.request({url});
assert.strictEqual(impersonated.credentials.access_token, 'qwerty345');
assert.strictEqual(
impersonated.credentials.expiry_date,
tomorrow.getTime()
);
scopes.forEach(s => s.done());
});

it('throws meaningful error when context available', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
Expand Down

0 comments on commit 835be89

Please sign in to comment.