diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts index a50499b2..ce149b63 100644 --- a/src/auth/credentials.ts +++ b/src/auth/credentials.ts @@ -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; diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index f4aad6f1..cc7be332 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -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, @@ -459,13 +463,72 @@ export class GoogleAuth { 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 = /(?[^/]+):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( @@ -476,6 +539,8 @@ export class GoogleAuth { 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, @@ -508,6 +573,8 @@ export class GoogleAuth { 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, diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index fb1085f6..061faec0 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -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; diff --git a/test/fixtures/impersonated_application_default_credentials.json b/test/fixtures/impersonated_application_default_credentials.json new file mode 100644 index 00000000..691768a0 --- /dev/null +++ b/test/fixtures/impersonated_application_default_credentials.json @@ -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" +} \ No newline at end of file diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index c2bc6bee..2bf13828 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -39,6 +39,7 @@ import { OAuth2Client, ExternalAccountClientOptions, RefreshOptions, + Impersonated, } from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; @@ -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}); diff --git a/test/test.impersonated.ts b/test/test.impersonated.ts index 9f2a9d8d..51de9592 100644 --- a/test/test.impersonated.ts +++ b/test/test.impersonated.ts @@ -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'; @@ -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);