Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(impersonated): add impersonated credentials auth #1207

Merged
merged 31 commits into from
Jul 29, 2021
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9ca71b8
Add impersonated credentials
salrashid123 Aug 29, 2019
9bd3590
feat(impersonated): Add Impersonated Credentials
salrashid123 Aug 29, 2019
b18dfc2
fix linter stuff
salrashid123 Aug 29, 2019
f91367c
Merge branch 'master' into master
bcoe Sep 5, 2019
c6e7c72
add endpoints override
salrashid123 Sep 7, 2019
706a473
Merge branch 'master' of https://github.com/salrashid123/google-auth-…
salrashid123 Sep 7, 2019
3caf0cb
Merge branch 'master' into master
bcoe Oct 14, 2019
fda2bab
Merge branch 'master' into master
bcoe Jan 16, 2020
30e81b4
Merge branch 'master' into master
bcoe Apr 27, 2020
c904057
Merge branch 'master' into master
bcoe May 21, 2020
28a448a
Merge branch 'master' into master
bcoe May 27, 2021
419a946
Merge branch 'master' into master
bcoe Jun 25, 2021
cf3b318
Merge branch 'master' into master
bcoe Jul 7, 2021
9b56c6c
test: make more idiomatic, cleanup tests
bcoe Jul 7, 2021
c6012bc
add tests for impersonated
bcoe Jul 16, 2021
1794dc5
Merge branch 'master' into impersonated
bcoe Jul 21, 2021
70f0821
test: increase coverage
bcoe Jul 21, 2021
c4e6310
update README
bcoe Jul 21, 2021
f53cb5b
update README
bcoe Jul 21, 2021
910c18c
slight copy edits
bcoe Jul 21, 2021
728573f
🦉 Updates from OwlBot
gcf-owl-bot[bot] Jul 21, 2021
180fdf5
chore: address review
bcoe Jul 22, 2021
24d2f42
chore: address review
bcoe Jul 22, 2021
e66f7ce
chore: address code review
bcoe Jul 22, 2021
8487662
chore: add comments
bcoe Jul 22, 2021
48fe1db
docs: fix example
bcoe Jul 22, 2021
e4f5917
chore: address code review
bcoe Jul 27, 2021
6cb0e20
chore: address additional review
bcoe Jul 27, 2021
2cf98e9
Merge branch 'master' into impersonated
bcoe Jul 28, 2021
a9752ee
🦉 Updates from OwlBot
gcf-owl-bot[bot] Jul 28, 2021
aa2178e
chore: address additional code review
bcoe Jul 29, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/auth/googleauth.ts
Expand Up @@ -30,6 +30,7 @@ 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 {
ExternalAccountClient,
ExternalAccountClientOptions,
Expand All @@ -44,7 +45,11 @@ import {AuthClient} from './authclient';
* Defines all types of explicit clients that are determined via ADC JSON
* config file.
*/
export type JSONClient = JWT | UserRefreshClient | BaseExternalAccountClient;
export type JSONClient =
| JWT
| UserRefreshClient
| BaseExternalAccountClient
| Impersonated;
bcoe marked this conversation as resolved.
Show resolved Hide resolved

export interface ProjectIdCallback {
(err?: Error | null, projectId?: string | null): void;
Expand Down Expand Up @@ -86,7 +91,11 @@ export interface GoogleAuthOptions {
/**
* Options object passed to the constructor of the client
*/
clientOptions?: JWTOptions | OAuth2ClientOptions | UserRefreshClientOptions;
clientOptions?:
| JWTOptions
| OAuth2ClientOptions
| UserRefreshClientOptions
| ImpersonatedOptions;

/**
* Required scopes for the desired API request
Expand Down Expand Up @@ -126,14 +135,13 @@ export class GoogleAuth {
// To save the contents of the JSON credential file
jsonContent: JWTInput | ExternalAccountClientOptions | null = null;

cachedCredential: JSONClient | Compute | null = null;
cachedCredential: JSONClient | Impersonated | Compute | null = null;

/**
* Scopes populated by the client library by default. We differentiate between
* these and user defined scopes when deciding whether to use a self-signed JWT.
*/
defaultScopes?: string | string[];

private keyFilename?: string;
private scopes?: string | string[];
private clientOptions?: RefreshOptions;
Expand Down
194 changes: 194 additions & 0 deletions src/auth/impersonated.ts
@@ -0,0 +1,194 @@
/**
* Copyright 2019 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2021

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {GaxiosError, GaxiosOptions, GaxiosPromise} from 'gaxios';
import {
GetTokenResponse,
Headers,
OAuth2Client,
RefreshOptions,
RequestMetadataResponse,
} from './oauth2client';

export interface ImpersonatedOptions extends RefreshOptions {
sourceClient?: OAuth2Client;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each of these properties, can we get a jsdoc annotation that describes what they are, and how they work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't sourceClient be an AuthClient? OAuth2Client is does not cover external account clients (BaseExternalAccountClient) which can impersonated service accounts.

targetPrincipal?: string;
targetScopes?: string[];
delegates?: string[];
lifetime?: number | 3600;
endpoint?: string | 'https://iamcredentials.googleapis.com';
bcoe marked this conversation as resolved.
Show resolved Hide resolved
}

export interface TokenResponse {
accessToken: string;
expireTime: string;
}

export class Impersonated extends OAuth2Client {
private sourceClient: OAuth2Client;
private targetPrincipal: string;
private targetScopes: string[];
private delegates: string[];
private lifetime: number;
private endpoint: string;

/**
* Impersonated service account credentials.
*
* Create a new access token by impersonating another service account.
*
* Impersonated Credentials allowing credentials issued to a user or
* service account to impersonate another. The source project using
* Impersonated Credentials must enable the "IAMCredentials" API.
* Also, the target service account must grant the orginating principal
* the "Service Account Token Creator" IAM role.
*
* @param credentials the service account email address.
* @param sourceClient the source credential used as to acquire the
* impersonated credentials
* @param targetPrincipal the service account to impersonate.
* @param delegates the chained list of delegates required to grant the
* final access_token. If set, the sequence of identities must have
* "Service Account Token Creator" capability granted to the preceding
* identity. For example, if set to [serviceAccountB, serviceAccountC],
* the sourceCredential must have the Token Creator role on serviceAccountB.
* serviceAccountB must have the Token Creator on serviceAccountC.
* Finally, C must have Token Creator on target_principal.
* If left unset, sourceCredential must have that role on targetPrincipal.
* @param targetScopes scopes to request during the authorization grant.
* @param lifetime number of seconds the delegated credential should be
* valid for (up to 3600).
bcoe marked this conversation as resolved.
Show resolved Hide resolved
* @param endpoint api endpoint override.
*/
constructor(options: ImpersonatedOptions = {}) {
super(options);
this.credentials = {
expiry_date: 1,
refresh_token: 'impersonated-placeholder',
};
this.sourceClient = options.sourceClient || new OAuth2Client();
this.targetPrincipal = options.targetPrincipal || '';
this.delegates = options.delegates || [];
this.targetScopes = options.targetScopes || [];
this.lifetime = options.lifetime || 3600;
bcoe marked this conversation as resolved.
Show resolved Hide resolved
this.endpoint = options.endpoint || 'https://iamcredentials.googleapis.com';
}

/**
* Refreshes the access token.
* @param refreshToken Unused parameter
*/
protected async refreshToken(
refreshToken?: string | null
): Promise<GetTokenResponse> {
const iat = Date.now();
if (this.credentials.expiry_date) {
if (this.credentials.expiry_date <= iat) {
try {
await this.sourceClient.getAccessToken();
bcoe marked this conversation as resolved.
Show resolved Hide resolved
const name = 'projects/-/serviceAccounts/' + this.targetPrincipal;
const u = `${this.endpoint}/v1/${name}:generateAccessToken`;
const body = {
delegates: this.delegates,
scope: this.targetScopes,
lifetime: this.lifetime + 's',
};
const res = await this.sourceClient.request({
url: u,
data: body,
method: 'POST',
});
const tokenResponse = res.data as TokenResponse;
this.credentials.access_token = tokenResponse.accessToken;
this.credentials.expiry_date =
Date.parse(tokenResponse.expireTime) / 1000;
return {
tokens: this.credentials,
res,
};
} catch (error) {
if (error.status === 403) {
if (error.message === 'The caller does not have permission') {
bcoe marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(
'Error: Unable to impersonate: sourceCredential lacks IAM Token Creator role on targetCredential'
);
}
if (
error.message ===
'Request had insufficient authentication scopes.'
) {
throw new Error(
'Error: Unable to impersonate: sourceCredential lacks cloud-platform or IAM scope'
);
}
}
throw new Error('Error: Unable to impersonate: ' + error);
}
}
return {tokens: this.credentials, res: null};
}
throw new Error('Error: Root credentials.expiry_date not set ');
}

protected requestAsync<T>(
bcoe marked this conversation as resolved.
Show resolved Hide resolved
opts: GaxiosOptions,
retry = false
): GaxiosPromise<T> {
try {
return super.requestAsync<T>(opts, retry);
} catch (err) {
let helpfulMessage = null;
if (err.status === 403) {
helpfulMessage =
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, perhaps we can get away with checking for just the 403 and 404 case?

'A Forbidden error was returned while attempting access the target Resource as ' +
'the Impersonated Account.';
} else if (err.status === 404) {
helpfulMessage = 'Target Resource was not found.';
}
if (helpfulMessage) {
if (!retry) {
helpfulMessage += ' ' + err.message;
}
err.message = helpfulMessage;
}
throw err;
}
}

/**
* Get a non-expired access token, after refreshing if necessary.
*
* @param url The URI being authorized.
* @returns An object that includes the authorization header.
*/
async getRequestHeaders(): Promise<Headers> {
const res = await this.getRequestMetadataAsync();
bcoe marked this conversation as resolved.
Show resolved Hide resolved
return res.headers;
}

protected async getRequestMetadataAsync(
url?: string | null
): Promise<RequestMetadataResponse> {
if (this.isTokenExpiring()) {
await this.getAccessToken();
}

const headers = {
Authorization: 'Bearer ' + this.credentials.access_token,
};
return {headers};
}
}
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -27,6 +27,7 @@ export {IAMAuth, RequestMetadata} from './auth/iam';
export {IdTokenClient, IdTokenProvider} from './auth/idtokenclient';
export {Claims, JWTAccess} from './auth/jwtaccess';
export {JWT, JWTOptions} from './auth/jwtclient';
export {Impersonated, ImpersonatedOptions} from './auth/impersonated';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to add a test for Impersonated:

it('should export all the things', () => {

export {
Certificates,
CodeChallengeMethod,
Expand Down
72 changes: 72 additions & 0 deletions test/test.impersonated.ts
@@ -0,0 +1,72 @@
/**
* Copyright 2019 Google LLC
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this one: 2021

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as assert from 'assert';
import * as nock from 'nock';
import {describe, it, afterEach} from 'mocha';
import {Impersonated, JWT} from '../src';
import {CredentialRequest} from '../src/auth/credentials';

const PEM_PATH = './test/fixtures/private.pem';

nock.disableNetConnect();

const url = 'http://example.com';

function createGTokenMock(body: CredentialRequest) {
return nock('https://www.googleapis.com')
bcoe marked this conversation as resolved.
Show resolved Hide resolved
.post('/oauth2/v4/token')
.reply(200, body);
}

describe('impersonated', () => {
afterEach(() => {
nock.cleanAll();
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding a new line after this and in between tests.

it('should request impersonated credentials on first request');
it.only('should refresh if access token has expired', async () => {
bcoe marked this conversation as resolved.
Show resolved Hide resolved
const scopes = [
nock(url).get('/').reply(200),
createGTokenMock({
access_token: 'abc123',
}),
];
const jwt = new JWT(
'foo@serviceaccount.com',
PEM_PATH,
undefined,
['http://bar', 'http://foo'],
'bar@subjectaccount.com'
);
await jwt.authorize();
const impersonated = new Impersonated({
sourceClient: jwt,
targetPrincipal: 'target@project.iam.gserviceaccount.com',
lifetime: 30,
delegates: [],
targetScopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
impersonated.credentials.access_token = 'initial-access-token';
impersonated.credentials.expiry_date = Date.now() - 10000;
await impersonated.request({url});
bcoe marked this conversation as resolved.
Show resolved Hide resolved
assert.strictEqual(impersonated.credentials.access_token, 'abc123');
scopes.forEach(s => s.done());
});
it(
'should throw appropriate exception when 403 occurs refreshing impersonated credentials'
);
it('should throw appropriate exception when 403 occurs during request');
bcoe marked this conversation as resolved.
Show resolved Hide resolved
});