Skip to content

Commit

Permalink
feature: Byoid metrics (#1595)
Browse files Browse the repository at this point in the history
* feat: adds byoid metrics logging

* add tests

* addressing PR comments
  • Loading branch information
aeitzman committed Jul 25, 2023
1 parent 90ea4de commit fe09d6b
Show file tree
Hide file tree
Showing 9 changed files with 480 additions and 52 deletions.
1 change: 1 addition & 0 deletions src/auth/awsclient.ts
Expand Up @@ -100,6 +100,7 @@ export class AwsClient extends BaseExternalAccountClient {
options.credential_source.imdsv2_session_token_url;
this.awsRequestSigner = null;
this.region = '';
this.credentialSourceType = 'aws';

// Data validators.
this.validateEnvironmentId();
Expand Down
29 changes: 26 additions & 3 deletions src/auth/baseexternalclient.ts
Expand Up @@ -59,6 +59,9 @@ export const CLOUD_RESOURCE_MANAGER =
const WORKFORCE_AUDIENCE_PATTERN =
'//iam.googleapis.com/locations/[^/]+/workforcePools/[^/]+/providers/.+';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require('../../../package.json');

/**
* Base external account credentials json interface.
*/
Expand Down Expand Up @@ -141,6 +144,8 @@ export abstract class BaseExternalAccountClient extends AuthClient {
public projectNumber: string | null;
public readonly eagerRefreshThresholdMillis: number;
public readonly forceRefreshOnFailure: boolean;
private readonly configLifetimeRequested: boolean;
protected credentialSourceType?: string;
/**
* Instantiate a BaseExternalAccountClient instance using the provided JSON
* object loaded from an external account credentials file.
Expand Down Expand Up @@ -191,9 +196,15 @@ export abstract class BaseExternalAccountClient extends AuthClient {
}
this.serviceAccountImpersonationUrl =
options.service_account_impersonation_url;

this.serviceAccountImpersonationLifetime =
options.service_account_impersonation?.token_lifetime_seconds ??
DEFAULT_TOKEN_LIFESPAN;
options.service_account_impersonation?.token_lifetime_seconds;
if (this.serviceAccountImpersonationLifetime) {
this.configLifetimeRequested = true;
} else {
this.configLifetimeRequested = false;
this.serviceAccountImpersonationLifetime = DEFAULT_TOKEN_LIFESPAN;
}
// As threshold could be zero,
// eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the
// zero value.
Expand Down Expand Up @@ -421,9 +432,12 @@ export abstract class BaseExternalAccountClient extends AuthClient {
!this.clientAuth && this.workforcePoolUserProject
? {userProject: this.workforcePoolUserProject}
: undefined;
const additionalHeaders: Headers = {
'x-goog-api-client': this.getMetricsHeaderValue(),
};
const stsResponse = await this.stsCredential.exchangeToken(
stsCredentialsOptions,
undefined,
additionalHeaders,
additionalOptions
);

Expand Down Expand Up @@ -544,4 +558,13 @@ export abstract class BaseExternalAccountClient extends AuthClient {
return this.scopes;
}
}

private getMetricsHeaderValue(): string {
const nodeVersion = process.version.replace(/^v/, '');
const saImpersonation = this.serviceAccountImpersonationUrl !== undefined;
const credentialSourceType = this.credentialSourceType
? this.credentialSourceType
: 'unknown';
return `gl-node/${nodeVersion} auth/${pkg.version} google-byoid-sdk source/${credentialSourceType} sa-impersonation/${saImpersonation} config-lifetime/${this.configLifetimeRequested}`;
}
}
14 changes: 12 additions & 2 deletions src/auth/identitypoolclient.ts
Expand Up @@ -86,8 +86,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient {
this.file = options.credential_source.file;
this.url = options.credential_source.url;
this.headers = options.credential_source.headers;
if (!this.file && !this.url) {
throw new Error('No valid Identity Pool "credential_source" provided');
if (this.file && this.url) {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
);
} else if (this.file && !this.url) {
this.credentialSourceType = 'file';
} else if (!this.file && this.url) {
this.credentialSourceType = 'url';
} else {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
);
}
// Text is the default format type.
this.formatType = options.credential_source.format?.type || 'text';
Expand Down
2 changes: 2 additions & 0 deletions src/auth/pluggable-auth-client.ts
Expand Up @@ -228,6 +228,8 @@ export class PluggableAuthClient extends BaseExternalAccountClient {
timeoutMillis: this.timeoutMillis,
outputFile: this.outputFile,
});

this.credentialSourceType = 'executable';
}

/**
Expand Down
12 changes: 12 additions & 0 deletions test/externalclienthelper.ts
Expand Up @@ -56,6 +56,9 @@ export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
const saBaseUrl = 'https://iamcredentials.googleapis.com';
const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`;

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require('../../package.json');

export function mockStsTokenExchange(
nockParams: NockMockStsToken[],
additionalHeaders?: {[key: string]: string}
Expand Down Expand Up @@ -132,3 +135,12 @@ export function mockCloudResourceManager(
.get(`/v1/projects/${projectNumber}`)
.reply(statusCode, response);
}

export function getExpectedExternalAccountMetricsHeaderValue(
expectedSource: string,
expectedSaImpersonation: boolean,
expectedConfigLifetime: boolean
): string {
const languageVersion = process.version.replace(/^v/, '');
return `gl-node/${languageVersion} auth/${pkg.version} google-byoid-sdk source/${expectedSource} sa-impersonation/${expectedSaImpersonation} config-lifetime/${expectedConfigLifetime}`;
}
50 changes: 50 additions & 0 deletions test/test.awsclient.ts
Expand Up @@ -26,6 +26,7 @@ import {
getServiceAccountImpersonationUrl,
mockGenerateAccessToken,
mockStsTokenExchange,
getExpectedExternalAccountMetricsHeaderValue,
} from './externalclienthelper';

nock.disableNetConnect();
Expand Down Expand Up @@ -989,6 +990,55 @@ describe('AwsClient', () => {
});
scope.done();
});

it('should set x-goog-api-client header correctly', async () => {
const scopes: nock.Scope[] = [];
scopes.push(
mockStsTokenExchange(
[
{
statusCode: 200,
response: stsSuccessfulResponse,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: expectedSubjectTokenNoToken,
subject_token_type:
'urn:ietf:params:aws:token-type:aws4_request',
},
},
],
{
'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue(
'aws',
false,
false
),
}
)
);
scopes.push(
nock(metadataBaseUrl)
.get('/latest/meta-data/placement/availability-zone')
.reply(200, `${awsRegion}b`)
);
process.env.AWS_ACCESS_KEY_ID = accessKeyId;
process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey;

const client = new AwsClient(awsOptions);
const actualResponse = await client.getAccessToken();

// Confirm raw GaxiosResponse appended to response.
assertGaxiosResponsePresent(actualResponse);
delete actualResponse.res;
assert.deepStrictEqual(actualResponse, {
token: stsSuccessfulResponse.access_token,
});
scopes.forEach(scope => scope.done());
});
});
});
});
160 changes: 160 additions & 0 deletions test/test.baseexternalclient.ts
Expand Up @@ -37,7 +37,9 @@ import {
mockCloudResourceManager,
mockGenerateAccessToken,
mockStsTokenExchange,
getExpectedExternalAccountMetricsHeaderValue,
} from './externalclienthelper';
import {RefreshOptions} from '../src';

nock.disableNetConnect();

Expand All @@ -50,6 +52,14 @@ interface SampleResponse {
class TestExternalAccountClient extends BaseExternalAccountClient {
private counter = 0;

constructor(
options: BaseExternalAccountClientOptions,
additionalOptions?: RefreshOptions
) {
super(options, additionalOptions);
this.credentialSourceType = 'test';
}

async retrieveSubjectToken(): Promise<string> {
// Increment subject_token counter each time this is called.
return `subject_token_${this.counter++}`;
Expand Down Expand Up @@ -1020,6 +1030,44 @@ describe('BaseExternalAccountClient', () => {
});
scope.done();
});

it('should send the correct x-goog-api-client header', async () => {
const scope = mockStsTokenExchange(
[
{
statusCode: 200,
response: stsSuccessfulResponse,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: 'subject_token_0',
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
},
],
{
'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue(
'test',
false,
false
),
}
);

const client = new TestExternalAccountClient(externalAccountOptions);
const actualResponse = await client.getAccessToken();

// Confirm raw GaxiosResponse appended to response.
assertGaxiosResponsePresent(actualResponse);
delete actualResponse.res;
assert.deepStrictEqual(actualResponse, {
token: stsSuccessfulResponse.access_token,
});
scope.done();
});
});

describe('with service account impersonation', () => {
Expand Down Expand Up @@ -1567,6 +1615,118 @@ describe('BaseExternalAccountClient', () => {
});
scopes.forEach(scope => scope.done());
});

it('should send correct x-goog-api-client header', async () => {
const scopes: nock.Scope[] = [];
scopes.push(
mockStsTokenExchange(
[
{
statusCode: 200,
response: stsSuccessfulResponse,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: 'subject_token_0',
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
},
],
{
'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue(
'test',
true,
false
),
}
)
);
scopes.push(
mockGenerateAccessToken({
statusCode: 200,
response: saSuccessResponse,
token: stsSuccessfulResponse.access_token,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
})
);

const client = new TestExternalAccountClient(
externalAccountOptionsWithSA
);
const actualResponse = await client.getAccessToken();

// Confirm raw GaxiosResponse appended to response.
assertGaxiosResponsePresent(actualResponse);
delete actualResponse.res;
assert.deepStrictEqual(actualResponse, {
token: saSuccessResponse.accessToken,
});
scopes.forEach(scope => scope.done());
});

it('should set correct x-goog-api-client header for custom token lifetime', async () => {
const scopes: nock.Scope[] = [];
scopes.push(
mockStsTokenExchange(
[
{
statusCode: 200,
response: stsSuccessfulResponse,
request: {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
audience,
scope: 'https://www.googleapis.com/auth/cloud-platform',
requested_token_type:
'urn:ietf:params:oauth:token-type:access_token',
subject_token: 'subject_token_0',
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
},
},
],
{
'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue(
'test',
true,
true
),
}
)
);
scopes.push(
mockGenerateAccessToken({
statusCode: 200,
response: saSuccessResponse,
token: stsSuccessfulResponse.access_token,
lifetime: 2800,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
})
);

const externalAccountOptionsWithSATokenLifespan = Object.assign(
{
service_account_impersonation: {
token_lifetime_seconds: 2800,
},
},
externalAccountOptionsWithSA
);

const client = new TestExternalAccountClient(
externalAccountOptionsWithSATokenLifespan
);
const actualResponse = await client.getAccessToken();

// Confirm raw GaxiosResponse appended to response.
assertGaxiosResponsePresent(actualResponse);
delete actualResponse.res;
assert.deepStrictEqual(actualResponse, {
token: saSuccessResponse.accessToken,
});
scopes.forEach(scope => scope.done());
});
});
});

Expand Down

0 comments on commit fe09d6b

Please sign in to comment.