Skip to content

Commit

Permalink
feat: adds support for creating AWS and Identity Pool credentials wit…
Browse files Browse the repository at this point in the history
…h custom suppliers. (#1783)

* feat: adds support for users to build credentials with custom suppliers

Also adds default values to make it easier to instantiate credentials in code.

* Apply suggestions from code review

Co-authored-by: Leo <[email protected]>

* responding to review comments

---------

Co-authored-by: Leo <[email protected]>
  • Loading branch information
aeitzman and lsirac authored Apr 3, 2024
1 parent 4ea7e8b commit 468367d
Show file tree
Hide file tree
Showing 8 changed files with 780 additions and 79 deletions.
80 changes: 56 additions & 24 deletions src/auth/awsclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ import {
} from './baseexternalclient';
import {AuthClientOptions} from './authclient';
import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier';
import {originalOrCamelOptions, SnakeToCamelObject} from '../util';

/**
* AWS credentials JSON interface. This is used for AWS workloads.
*/
export interface AwsClientOptions extends BaseExternalAccountClientOptions {
credential_source: {
credential_source?: {
environment_id: string;
// Region can also be determined from the AWS_REGION or AWS_DEFAULT_REGION
// environment variables.
Expand All @@ -43,6 +44,7 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions {
// The session token is required for IMDSv2 but optional for IMDSv1
imdsv2_session_token_url?: string;
};
aws_security_credentials_supplier?: AwsSecurityCredentialsSupplier;
}

/**
Expand Down Expand Up @@ -82,12 +84,15 @@ export interface AwsSecurityCredentialsSupplier {
* GCP access token.
*/
export class AwsClient extends BaseExternalAccountClient {
private readonly environmentId: string;
private readonly environmentId?: string;
private readonly awsSecurityCredentialsSupplier: AwsSecurityCredentialsSupplier;
private readonly regionalCredVerificationUrl: string;
private awsRequestSigner: AwsRequestSigner | null;
private region: string;

static #DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL =
'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15';

/**
* @deprecated AWS client no validates the EC2 metadata address.
**/
Expand All @@ -109,34 +114,61 @@ export class AwsClient extends BaseExternalAccountClient {
* on 401/403 API request errors.
*/
constructor(
options: AwsClientOptions,
options: AwsClientOptions | SnakeToCamelObject<AwsClientOptions>,
additionalOptions?: AuthClientOptions
) {
super(options, additionalOptions);
this.environmentId = options.credential_source.environment_id;
// This is only required if the AWS region is not available in the
// AWS_REGION or AWS_DEFAULT_REGION environment variables.
const regionUrl = options.credential_source.region_url;
// This is only required if AWS security credentials are not available in
// environment variables.
const securityCredentialsUrl = options.credential_source.url;
const imdsV2SessionTokenUrl =
options.credential_source.imdsv2_session_token_url;
this.awsSecurityCredentialsSupplier =
new DefaultAwsSecurityCredentialsSupplier({
regionUrl: regionUrl,
securityCredentialsUrl: securityCredentialsUrl,
imdsV2SessionTokenUrl: imdsV2SessionTokenUrl,
});
const opts = originalOrCamelOptions(options as AwsClientOptions);
const credentialSource = opts.get('credential_source');
const awsSecurityCredentialsSupplier = opts.get(
'aws_security_credentials_supplier'
);
// Validate credential sourcing configuration.
if (!credentialSource && !awsSecurityCredentialsSupplier) {
throw new Error(
'A credential source or AWS security credentials supplier must be specified.'
);
}
if (credentialSource && awsSecurityCredentialsSupplier) {
throw new Error(
'Only one of credential source or AWS security credentials supplier can be specified.'
);
}

if (awsSecurityCredentialsSupplier) {
this.awsSecurityCredentialsSupplier = awsSecurityCredentialsSupplier;
this.regionalCredVerificationUrl =
AwsClient.#DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL;
this.credentialSourceType = 'programmatic';
} else {
const credentialSourceOpts = originalOrCamelOptions(credentialSource);
this.environmentId = credentialSourceOpts.get('environment_id');
// This is only required if the AWS region is not available in the
// AWS_REGION or AWS_DEFAULT_REGION environment variables.
const regionUrl = credentialSourceOpts.get('region_url');
// This is only required if AWS security credentials are not available in
// environment variables.
const securityCredentialsUrl = credentialSourceOpts.get('url');
const imdsV2SessionTokenUrl = credentialSourceOpts.get(
'imdsv2_session_token_url'
);
this.awsSecurityCredentialsSupplier =
new DefaultAwsSecurityCredentialsSupplier({
regionUrl: regionUrl,
securityCredentialsUrl: securityCredentialsUrl,
imdsV2SessionTokenUrl: imdsV2SessionTokenUrl,
});

this.regionalCredVerificationUrl =
options.credential_source.regional_cred_verification_url;
this.regionalCredVerificationUrl = credentialSourceOpts.get(
'regional_cred_verification_url'
);
this.credentialSourceType = 'aws';

// Data validators.
this.validateEnvironmentId();
}
this.awsRequestSigner = null;
this.region = '';
this.credentialSourceType = 'aws';

// Data validators.
this.validateEnvironmentId();
}

private validateEnvironmentId() {
Expand Down
12 changes: 8 additions & 4 deletions src/auth/baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const CLOUD_RESOURCE_MANAGER =
/** The workforce audience pattern. */
const WORKFORCE_AUDIENCE_PATTERN =
'//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+';
const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require('../../../package.json');
Expand All @@ -75,7 +76,7 @@ export {DEFAULT_UNIVERSE} from './authclient';

export interface SharedExternalAccountClientOptions extends AuthClientOptions {
audience: string;
token_url: string;
token_url?: string;
}

/**
Expand Down Expand Up @@ -108,7 +109,7 @@ export interface ExternalAccountSupplierContext {
*/
export interface BaseExternalAccountClientOptions
extends SharedExternalAccountClientOptions {
type: string;
type?: string;
subject_token_type: string;
service_account_impersonation_url?: string;
service_account_impersonation?: {
Expand Down Expand Up @@ -217,7 +218,8 @@ export abstract class BaseExternalAccountClient extends AuthClient {
options as BaseExternalAccountClientOptions
);

if (opts.get('type') !== EXTERNAL_ACCOUNT_TYPE) {
const type = opts.get('type');
if (type && type !== EXTERNAL_ACCOUNT_TYPE) {
throw new Error(
`Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` +
`received "${options.type}"`
Expand All @@ -226,7 +228,9 @@ export abstract class BaseExternalAccountClient extends AuthClient {

const clientId = opts.get('client_id');
const clientSecret = opts.get('client_secret');
const tokenUrl = opts.get('token_url');
const tokenUrl =
opts.get('token_url') ??
DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain);
const subjectTokenType = opts.get('subject_token_type');
const workforcePoolUserProject = opts.get('workforce_pool_user_project');
const serviceAccountImpersonationUrl = opts.get(
Expand Down
11 changes: 6 additions & 5 deletions src/auth/externalAccountAuthorizedUserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
*/
export const EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE =
'external_account_authorized_user';
const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/oauthtoken';

/**
* External Account Authorized User Credentials JSON interface.
Expand Down Expand Up @@ -171,6 +172,9 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient {
additionalOptions?: AuthClientOptions
) {
super({...options, ...additionalOptions});
if (options.universe_domain) {
this.universeDomain = options.universe_domain;
}
this.refreshToken = options.refresh_token;
const clientAuth = {
confidentialClientType: 'basic',
Expand All @@ -179,7 +183,8 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient {
} as ClientAuthentication;
this.externalAccountAuthorizedUserHandler =
new ExternalAccountAuthorizedUserHandler(
options.token_url,
options.token_url ??
DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain),
this.transporter,
clientAuth
);
Expand All @@ -197,10 +202,6 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient {
.eagerRefreshThresholdMillis as number;
}
this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure;

if (options.universe_domain) {
this.universeDomain = options.universe_domain;
}
}

async getAccessToken(): Promise<{
Expand Down
101 changes: 60 additions & 41 deletions src/auth/identitypoolclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface SubjectTokenSupplier {
*/
export interface IdentityPoolClientOptions
extends BaseExternalAccountClientOptions {
credential_source: {
credential_source?: {
file?: string;
url?: string;
headers?: {
Expand All @@ -64,6 +64,7 @@ export interface IdentityPoolClientOptions
subject_token_field_name?: string;
};
};
subject_token_supplier?: SubjectTokenSupplier;
}

/**
Expand Down Expand Up @@ -97,53 +98,71 @@ export class IdentityPoolClient extends BaseExternalAccountClient {

const opts = originalOrCamelOptions(options as IdentityPoolClientOptions);
const credentialSource = opts.get('credential_source');
const credentialSourceOpts = originalOrCamelOptions(credentialSource);

const formatOpts = originalOrCamelOptions(
credentialSourceOpts.get('format')
);

// Text is the default format type.
const formatType = formatOpts.get('type') || 'text';
const formatSubjectTokenFieldName = formatOpts.get(
'subject_token_field_name'
);

if (formatType !== 'json' && formatType !== 'text') {
throw new Error(`Invalid credential_source format "${formatType}"`);
}
if (formatType === 'json' && !formatSubjectTokenFieldName) {
const subjectTokenSupplier = opts.get('subject_token_supplier');
// Validate credential sourcing configuration.
if (!credentialSource && !subjectTokenSupplier) {
throw new Error(
'Missing subject_token_field_name for JSON credential_source format'
'A credential source or subject token supplier must be specified.'
);
}

const file = credentialSourceOpts.get('file');
const url = credentialSourceOpts.get('url');
const headers = credentialSourceOpts.get('headers');
if (file && url) {
if (credentialSource && subjectTokenSupplier) {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
'Only one of credential source or subject token supplier can be specified.'
);
} else if (file && !url) {
this.credentialSourceType = 'file';
this.subjectTokenSupplier = new FileSubjectTokenSupplier({
filePath: file,
formatType: formatType,
subjectTokenFieldName: formatSubjectTokenFieldName,
});
} else if (!file && url) {
this.credentialSourceType = 'url';
this.subjectTokenSupplier = new UrlSubjectTokenSupplier({
url: url,
formatType: formatType,
subjectTokenFieldName: formatSubjectTokenFieldName,
headers: headers,
});
}

if (subjectTokenSupplier) {
this.subjectTokenSupplier = subjectTokenSupplier;
this.credentialSourceType = 'programmatic';
} else {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
const credentialSourceOpts = originalOrCamelOptions(credentialSource);

const formatOpts = originalOrCamelOptions(
credentialSourceOpts.get('format')
);

// Text is the default format type.
const formatType = formatOpts.get('type') || 'text';
const formatSubjectTokenFieldName = formatOpts.get(
'subject_token_field_name'
);

if (formatType !== 'json' && formatType !== 'text') {
throw new Error(`Invalid credential_source format "${formatType}"`);
}
if (formatType === 'json' && !formatSubjectTokenFieldName) {
throw new Error(
'Missing subject_token_field_name for JSON credential_source format'
);
}

const file = credentialSourceOpts.get('file');
const url = credentialSourceOpts.get('url');
const headers = credentialSourceOpts.get('headers');
if (file && url) {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
);
} else if (file && !url) {
this.credentialSourceType = 'file';
this.subjectTokenSupplier = new FileSubjectTokenSupplier({
filePath: file,
formatType: formatType,
subjectTokenFieldName: formatSubjectTokenFieldName,
});
} else if (!file && url) {
this.credentialSourceType = 'url';
this.subjectTokenSupplier = new UrlSubjectTokenSupplier({
url: url,
formatType: formatType,
subjectTokenFieldName: formatSubjectTokenFieldName,
headers: headers,
});
} else {
throw new Error(
'No valid Identity Pool "credential_source" provided, must be either file or url.'
);
}
}
}

Expand Down
Loading

0 comments on commit 468367d

Please sign in to comment.