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: add GoogleAuth.sign() support to external account client #1227

Merged
merged 2 commits into from
Aug 10, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
3 changes: 2 additions & 1 deletion samples/scripts/externalclient-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
// identity pools).
// 2. Security Admin (needed to get and set IAM policies).
// 3. Service Account Token Creator (needed to generate Google ID tokens and
// access tokens).
// access tokens). This is also needed to call the IAMCredentials signBlob
// API.
//
// The following APIs need to be enabled on the project:
// 1. Identity and Access Management (IAM) API.
Expand Down
38 changes: 35 additions & 3 deletions samples/test/externalclient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe('samples for external-account', () => {
type: 'external_account',
audience: AUDIENCE_OIDC,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
token_url: 'https://sts.googleapis.com/v1beta/token',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url:
'https://iamcredentials.googleapis.com/v1/projects/' +
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
Expand All @@ -285,6 +285,38 @@ describe('samples for external-account', () => {
assert.match(output, /DNS Info:/);
});

it('should sign the blobs with IAM credentials API', async () => {
// Create file-sourced configuration JSON file.
// The created OIDC token will be used as the subject token and will be
// retrieved from a file location.
const config = {
type: 'external_account',
audience: AUDIENCE_OIDC,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url:
'https://iamcredentials.googleapis.com/v1/projects/' +
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
credential_source: {
file: oidcTokenFilePath,
},
};
await writeFile(oidcTokenFilePath, oidcToken);
await writeFile(configFilePath, JSON.stringify(config));

// Run sample script with GOOGLE_APPLICATION_CREDENTIALS envvar
// pointing to the temporarily created configuration file.
// This script will use signBlob to sign some data using
// service account impersonated workload identity pool credentials.
const output = await execAsync(`${process.execPath} signBlob`, {
env: {
...process.env,
GOOGLE_APPLICATION_CREDENTIALS: configFilePath,
},
});
assert.ok(output.length > 0);
});

it('should acquire ADC for url-sourced creds', async () => {
// Create url-sourced configuration JSON file.
// The created OIDC token will be used as the subject token and will be
Expand All @@ -293,7 +325,7 @@ describe('samples for external-account', () => {
type: 'external_account',
audience: AUDIENCE_OIDC,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
token_url: 'https://sts.googleapis.com/v1beta/token',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url:
'https://iamcredentials.googleapis.com/v1/projects/' +
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
Expand Down Expand Up @@ -358,7 +390,7 @@ describe('samples for external-account', () => {
type: 'external_account',
audience: AUDIENCE_AWS,
subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request',
token_url: 'https://sts.googleapis.com/v1beta/token',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url:
'https://iamcredentials.googleapis.com/v1/projects/' +
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
Expand Down
13 changes: 13 additions & 0 deletions src/auth/baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ export abstract class BaseExternalAccountClient extends AuthClient {
this.projectNumber = this.getProjectNumber(this.audience);
}

/** The service account email to be impersonated, if available. */
getServiceAccountEmail(): string | null {
if (this.serviceAccountImpersonationUrl) {
// Parse email from URL. The formal looks as follows:
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
const matches = this.serviceAccountImpersonationUrl.match(
/serviceAccounts\/([^:]+):generateAccessToken$/
bojeil-google marked this conversation as resolved.
Show resolved Hide resolved
);
return (matches && matches[1]) || null;
}
return null;
}

/**
* Provides a mechanism to inject GCP access tokens directly.
* When the provided credential expires, a new credential, using the
Expand Down
31 changes: 29 additions & 2 deletions src/auth/googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import * as os from 'os';
import * as path from 'path';
import * as stream from 'stream';

import {createCrypto} from '../crypto/crypto';
import {Crypto, createCrypto} from '../crypto/crypto';
import {DefaultTransporter, Transporter} from '../transporters';

import {Compute, ComputeOptions} from './computeclient';
Expand Down Expand Up @@ -877,6 +877,23 @@ export class GoogleAuth {
return sign;
}

// signBlob requires a service account email and the underlying
// access token to have iam.serviceAccounts.signBlob permission
// on the specified resource name.
// The "Service Account Token Creator" role should cover this.
// As a result external account credentials can support this
// operation when service account impersonation is enabled.
if (
client instanceof BaseExternalAccountClient &&
client.getServiceAccountEmail()
) {
return this.signBlob(
crypto,
client.getServiceAccountEmail() as string,
data
);
}

const projectId = await this.getProjectId();
if (!projectId) {
throw new Error('Cannot sign data without a project ID.');
Expand All @@ -887,7 +904,17 @@ export class GoogleAuth {
throw new Error('Cannot sign data without `client_email`.');
}

const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${creds.client_email}:signBlob`;
return this.signBlob(crypto, creds.client_email, data);
}

private async signBlob(
crypto: Crypto,
emailOrUniqueId: string,
data: string
): Promise<string> {
const url =
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' +
`${emailOrUniqueId}:signBlob`;
const res = await this.request<SignBlobResponse>({
method: 'POST',
url,
Expand Down
2 changes: 1 addition & 1 deletion test/externalclienthelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const poolId = 'POOL_ID';
const providerId = 'PROVIDER_ID';
const baseUrl = 'https://sts.googleapis.com';
const path = '/v1/token';
const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
const saBaseUrl = 'https://iamcredentials.googleapis.com';
const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`;

Expand Down
42 changes: 42 additions & 0 deletions test/test.baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {StsSuccessfulResponse} from '../src/auth/stscredentials';
import {
EXPIRATION_TIME_OFFSET,
BaseExternalAccountClient,
BaseExternalAccountClientOptions,
} from '../src/auth/baseexternalclient';
import {
OAuthErrorResponse,
Expand Down Expand Up @@ -190,6 +191,47 @@ describe('BaseExternalAccountClient', () => {
});
});

describe('getServiceAccountEmail()', () => {
it('should return the service account email when impersonation is used', () => {
const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
const saBaseUrl = 'https://iamcredentials.googleapis.com';
const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`;
const options: BaseExternalAccountClientOptions = Object.assign(
{},
externalAccountOptions
);
options.service_account_impersonation_url = `${saBaseUrl}${saPath}`;
const client = new TestExternalAccountClient(options);

assert.strictEqual(client.getServiceAccountEmail(), saEmail);
});

it('should return null when impersonation is not used', () => {
const options: BaseExternalAccountClientOptions = Object.assign(
{},
externalAccountOptions
);
delete options.service_account_impersonation_url;
const client = new TestExternalAccountClient(options);

assert(client.getServiceAccountEmail() === null);
});

it('should return null when impersonation url is malformed', () => {
const saBaseUrl = 'https://iamcredentials.googleapis.com';
// Malformed path (missing the service account email).
const saPath = '/v1/projects/-/serviceAccounts/:generateAccessToken';
const options: BaseExternalAccountClientOptions = Object.assign(
{},
externalAccountOptions
);
options.service_account_impersonation_url = `${saBaseUrl}${saPath}`;
const client = new TestExternalAccountClient(options);

assert(client.getServiceAccountEmail() === null);
});
});

describe('getProjectId()', () => {
it('should resolve with projectId when determinable', async () => {
const projectNumber = 'my-proj-number';
Expand Down
94 changes: 81 additions & 13 deletions test/test.googleauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ import {CredentialBody} from '../src/auth/credentials';
import * as envDetect from '../src/auth/envDetect';
import {Compute} from '../src/auth/computeclient';
import {
getServiceAccountImpersonationUrl,
mockCloudResourceManager,
mockGenerateAccessToken,
mockStsTokenExchange,
saEmail,
} from './externalclienthelper';
import {BaseExternalAccountClient} from '../src/auth/baseexternalclient';
import {AuthClient} from '../src/auth/authclient';
Expand Down Expand Up @@ -1596,6 +1599,11 @@ describe('googleauth', () => {
expires_in: 3600,
scope: 'scope1 scope2',
};
const now = new Date().getTime();
const saSuccessResponse = {
accessToken: 'SA_ACCESS_TOKEN',
expireTime: new Date(now + 3600 * 1000).toISOString(),
};
const fileSubjectToken = fs.readFileSync(
externalAccountJSON.credential_source.file,
'utf-8'
Expand Down Expand Up @@ -1640,13 +1648,19 @@ describe('googleauth', () => {
* manager.
* @param mockProjectIdRetrieval Whether to mock project ID retrieval.
* @param expectedScopes The list of expected scopes.
* @param mockServiceAccountImpersonation Whether to mock IAMCredentials
* GenerateAccessToken.
* @return The list of nock.Scope corresponding to the mocked HTTP
* requests.
*/
function mockGetAccessTokenAndProjectId(
mockProjectIdRetrieval = true,
expectedScopes = ['https://www.googleapis.com/auth/cloud-platform']
expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'],
mockServiceAccountImpersonation = false
): nock.Scope[] {
const stsScopes = mockServiceAccountImpersonation
? 'https://www.googleapis.com/auth/cloud-platform'
: expectedScopes.join(' ');
const scopes = [
mockStsTokenExchange([
{
Expand All @@ -1655,7 +1669,7 @@ describe('googleauth', () => {
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience: externalAccountJSON.audience,
scope: expectedScopes.join(' '),
scope: stsScopes,
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: fileSubjectToken,
Expand All @@ -1664,6 +1678,18 @@ describe('googleauth', () => {
},
]),
];
if (mockServiceAccountImpersonation) {
scopes.push(
mockGenerateAccessToken([
{
statusCode: 200,
response: saSuccessResponse,
token: stsSuccessfulResponse.access_token,
scopes: expectedScopes,
},
])
);
}

if (mockProjectIdRetrieval) {
scopes.push(
Expand Down Expand Up @@ -2163,24 +2189,66 @@ describe('googleauth', () => {
});
});

it('getIdTokenClient() should reject', async () => {
const auth = new GoogleAuth({credentials: createExternalAccountJSON()});
describe('sign()', () => {
it('should reject when no impersonation is used', async () => {
const scopes = mockGetAccessTokenAndProjectId();
const auth = new GoogleAuth({
credentials: createExternalAccountJSON(),
});

await assert.rejects(
auth.getIdTokenClient('a-target-audience'),
/Cannot fetch ID token in this environment/
);
await assert.rejects(
auth.sign('abc123'),
/Cannot sign data without `client_email`/
);
scopes.forEach(s => s.done());
});

it('should use IAMCredentials endpoint when impersonation is used', async () => {
const scopes = mockGetAccessTokenAndProjectId(
false,
['https://www.googleapis.com/auth/cloud-platform'],
true
);
const email = saEmail;
const configWithImpersonation = createExternalAccountJSON();
configWithImpersonation.service_account_impersonation_url =
getServiceAccountImpersonationUrl();
const iamUri = 'https://iamcredentials.googleapis.com';
const iamPath = `/v1/projects/-/serviceAccounts/${email}:signBlob`;
const signedBlob = 'erutangis';
const data = 'abc123';
scopes.push(
nock(iamUri)
.post(
iamPath,
{
payload: Buffer.from(data, 'utf-8').toString('base64'),
},
{
reqheaders: {
Authorization: `Bearer ${saSuccessResponse.accessToken}`,
'Content-Type': 'application/json',
},
}
)
.reply(200, {signedBlob})
);
const auth = new GoogleAuth({credentials: configWithImpersonation});

const value = await auth.sign(data);

scopes.forEach(x => x.done());
assert.strictEqual(value, signedBlob);
});
});

it('sign() should reject', async () => {
const scopes = mockGetAccessTokenAndProjectId();
it('getIdTokenClient() should reject', async () => {
const auth = new GoogleAuth({credentials: createExternalAccountJSON()});

await assert.rejects(
auth.sign('abc123'),
/Cannot sign data without `client_email`/
auth.getIdTokenClient('a-target-audience'),
/Cannot fetch ID token in this environment/
);
scopes.forEach(s => s.done());
});

it('getAccessToken() should get an access token', async () => {
Expand Down