Skip to content

Commit

Permalink
feat: Impersonated Universe Domain Support (#1875)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
d-goog authored Nov 1, 2024
1 parent a65d8a1 commit 902bf8b
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 16 deletions.
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);

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 ?? [],
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,
};

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

0 comments on commit 902bf8b

Please sign in to comment.