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

feature: Byoid metrics #1595

Merged
merged 5 commits into from
Jul 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/auth/awsclient.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading