Skip to content

Commit

Permalink
refactor(NODE-6040): extract aws temporary credential acquisition log…
Browse files Browse the repository at this point in the history
…ic into a standalone module (#4050)
  • Loading branch information
baileympearson committed Mar 25, 2024
1 parent d94439f commit 918fe69
Show file tree
Hide file tree
Showing 5 changed files with 194 additions and 130 deletions.
167 changes: 167 additions & 0 deletions src/cmap/auth/aws_temporary_credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { type AWSCredentials, getAwsCredentialProvider } from '../../deps';
import { MongoAWSError } from '../../error';
import { request } from '../../utils';

const AWS_RELATIVE_URI = 'http://169.254.170.2';
const AWS_EC2_URI = 'http://169.254.169.254';
const AWS_EC2_PATH = '/latest/meta-data/iam/security-credentials';

/**
* @internal
* This interface matches the final result of fetching temporary credentials manually, outlined
* in the spec [here](https://github.com/mongodb/specifications/blob/master/source/auth/auth.md#ec2-endpoint).
*
* When we use the AWS SDK, we map the response from the SDK to conform to this interface.
*/
export interface AWSTempCredentials {
AccessKeyId?: string;
SecretAccessKey?: string;
Token?: string;
RoleArn?: string;
Expiration?: Date;
}

/**
* @internal
*
* Fetches temporary AWS credentials.
*/
export abstract class AWSTemporaryCredentialProvider {
abstract getCredentials(): Promise<AWSTempCredentials>;
private static _awsSDK: ReturnType<typeof getAwsCredentialProvider>;
protected static get awsSDK() {
AWSTemporaryCredentialProvider._awsSDK ??= getAwsCredentialProvider();
return AWSTemporaryCredentialProvider._awsSDK;
}

static get isAWSSDKInstalled(): boolean {
return !('kModuleError' in AWSTemporaryCredentialProvider.awsSDK);
}
}

/** @internal */
export class AWSSDKCredentialProvider extends AWSTemporaryCredentialProvider {
private _provider?: () => Promise<AWSCredentials>;
/**
* The AWS SDK caches credentials automatically and handles refresh when the credentials have expired.
* To ensure this occurs, we need to cache the `provider` returned by the AWS sdk and re-use it when fetching credentials.
*/
private get provider(): () => Promise<AWSCredentials> {
if ('kModuleError' in AWSTemporaryCredentialProvider.awsSDK) {
throw AWSTemporaryCredentialProvider.awsSDK.kModuleError;
}
if (this._provider) {
return this._provider;
}
let { AWS_STS_REGIONAL_ENDPOINTS = '', AWS_REGION = '' } = process.env;
AWS_STS_REGIONAL_ENDPOINTS = AWS_STS_REGIONAL_ENDPOINTS.toLowerCase();
AWS_REGION = AWS_REGION.toLowerCase();

/** The option setting should work only for users who have explicit settings in their environment, the driver should not encode "defaults" */
const awsRegionSettingsExist =
AWS_REGION.length !== 0 && AWS_STS_REGIONAL_ENDPOINTS.length !== 0;

/**
* The following regions use the global AWS STS endpoint, sts.amazonaws.com, by default
* https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html
*/
const LEGACY_REGIONS = new Set([
'ap-northeast-1',
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'aws-global',
'ca-central-1',
'eu-central-1',
'eu-north-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'sa-east-1',
'us-east-1',
'us-east-2',
'us-west-1',
'us-west-2'
]);
/**
* If AWS_STS_REGIONAL_ENDPOINTS is set to regional, users are opting into the new behavior of respecting the region settings
*
* If AWS_STS_REGIONAL_ENDPOINTS is set to legacy, then "old" regions need to keep using the global setting.
* Technically the SDK gets this wrong, it reaches out to 'sts.us-east-1.amazonaws.com' when it should be 'sts.amazonaws.com'.
* That is not our bug to fix here. We leave that up to the SDK.
*/
const useRegionalSts =
AWS_STS_REGIONAL_ENDPOINTS === 'regional' ||
(AWS_STS_REGIONAL_ENDPOINTS === 'legacy' && !LEGACY_REGIONS.has(AWS_REGION));

this._provider =
awsRegionSettingsExist && useRegionalSts
? AWSTemporaryCredentialProvider.awsSDK.fromNodeProviderChain({
clientConfig: { region: AWS_REGION }
})
: AWSTemporaryCredentialProvider.awsSDK.fromNodeProviderChain();

return this._provider;
}

override async getCredentials(): Promise<AWSTempCredentials> {
/*
* Creates a credential provider that will attempt to find credentials from the
* following sources (listed in order of precedence):
*
* - Environment variables exposed via process.env
* - SSO credentials from token cache
* - Web identity token credentials
* - Shared credentials and config ini files
* - The EC2/ECS Instance Metadata Service
*/
try {
const creds = await this.provider();
return {
AccessKeyId: creds.accessKeyId,
SecretAccessKey: creds.secretAccessKey,
Token: creds.sessionToken,
Expiration: creds.expiration
};
} catch (error) {
throw new MongoAWSError(error.message, { cause: error });
}
}
}

/**
* @internal
* Fetches credentials manually (without the AWS SDK), as outlined in the [Obtaining Credentials](https://github.com/mongodb/specifications/blob/master/source/auth/auth.md#obtaining-credentials)
* section of the Auth spec.
*/
export class LegacyAWSTemporaryCredentialProvider extends AWSTemporaryCredentialProvider {
override async getCredentials(): Promise<AWSTempCredentials> {
// If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
// is set then drivers MUST assume that it was set by an AWS ECS agent
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
return request(`${AWS_RELATIVE_URI}${process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`);
}

// Otherwise assume we are on an EC2 instance

// get a token
const token = await request(`${AWS_EC2_URI}/latest/api/token`, {
method: 'PUT',
json: false,
headers: { 'X-aws-ec2-metadata-token-ttl-seconds': 30 }
});

// get role name
const roleName = await request(`${AWS_EC2_URI}/${AWS_EC2_PATH}`, {
json: false,
headers: { 'X-aws-ec2-metadata-token': token }
});

// get temp credentials
const creds = await request(`${AWS_EC2_URI}/${AWS_EC2_PATH}/${roleName}`, {
headers: { 'X-aws-ec2-metadata-token': token }
});

return creds;
}
}
145 changes: 19 additions & 126 deletions src/cmap/auth/mongodb_aws.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,23 @@
import * as process from 'process';

import type { Binary, BSONSerializeOptions } from '../../bson';
import * as BSON from '../../bson';
import { aws4, type AWSCredentials, getAwsCredentialProvider } from '../../deps';
import { aws4 } from '../../deps';
import {
MongoAWSError,
MongoCompatibilityError,
MongoMissingCredentialsError,
MongoRuntimeError
} from '../../error';
import { ByteUtils, maxWireVersion, ns, randomBytes, request } from '../../utils';
import { ByteUtils, maxWireVersion, ns, randomBytes } from '../../utils';
import { type AuthContext, AuthProvider } from './auth_provider';
import {
AWSSDKCredentialProvider,
type AWSTempCredentials,
AWSTemporaryCredentialProvider,
LegacyAWSTemporaryCredentialProvider
} from './aws_temporary_credentials';
import { MongoCredentials } from './mongo_credentials';
import { AuthMechanism } from './providers';

/**
* The following regions use the global AWS STS endpoint, sts.amazonaws.com, by default
* https://docs.aws.amazon.com/sdkref/latest/guide/feature-sts-regionalized-endpoints.html
*/
const LEGACY_REGIONS = new Set([
'ap-northeast-1',
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'aws-global',
'ca-central-1',
'eu-central-1',
'eu-north-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'sa-east-1',
'us-east-1',
'us-east-2',
'us-west-1',
'us-west-2'
]);
const ASCII_N = 110;
const AWS_RELATIVE_URI = 'http://169.254.170.2';
const AWS_EC2_URI = 'http://169.254.169.254';
const AWS_EC2_PATH = '/latest/meta-data/iam/security-credentials';
const bsonOptions: BSONSerializeOptions = {
useBigInt64: false,
promoteLongs: true,
Expand All @@ -55,40 +33,13 @@ interface AWSSaslContinuePayload {
}

export class MongoDBAWS extends AuthProvider {
static credentialProvider: ReturnType<typeof getAwsCredentialProvider>;
provider?: () => Promise<AWSCredentials>;

private credentialFetcher: AWSTemporaryCredentialProvider;
constructor() {
super();
MongoDBAWS.credentialProvider ??= getAwsCredentialProvider();

let { AWS_STS_REGIONAL_ENDPOINTS = '', AWS_REGION = '' } = process.env;
AWS_STS_REGIONAL_ENDPOINTS = AWS_STS_REGIONAL_ENDPOINTS.toLowerCase();
AWS_REGION = AWS_REGION.toLowerCase();

/** The option setting should work only for users who have explicit settings in their environment, the driver should not encode "defaults" */
const awsRegionSettingsExist =
AWS_REGION.length !== 0 && AWS_STS_REGIONAL_ENDPOINTS.length !== 0;

/**
* If AWS_STS_REGIONAL_ENDPOINTS is set to regional, users are opting into the new behavior of respecting the region settings
*
* If AWS_STS_REGIONAL_ENDPOINTS is set to legacy, then "old" regions need to keep using the global setting.
* Technically the SDK gets this wrong, it reaches out to 'sts.us-east-1.amazonaws.com' when it should be 'sts.amazonaws.com'.
* That is not our bug to fix here. We leave that up to the SDK.
*/
const useRegionalSts =
AWS_STS_REGIONAL_ENDPOINTS === 'regional' ||
(AWS_STS_REGIONAL_ENDPOINTS === 'legacy' && !LEGACY_REGIONS.has(AWS_REGION));

if ('fromNodeProviderChain' in MongoDBAWS.credentialProvider) {
this.provider =
awsRegionSettingsExist && useRegionalSts
? MongoDBAWS.credentialProvider.fromNodeProviderChain({
clientConfig: { region: AWS_REGION }
})
: MongoDBAWS.credentialProvider.fromNodeProviderChain();
}
this.credentialFetcher = AWSTemporaryCredentialProvider.isAWSSDKInstalled
? new AWSSDKCredentialProvider()
: new LegacyAWSTemporaryCredentialProvider();
}

override async auth(authContext: AuthContext): Promise<void> {
Expand All @@ -109,7 +60,10 @@ export class MongoDBAWS extends AuthProvider {
}

if (!authContext.credentials.username) {
authContext.credentials = await makeTempCredentials(authContext.credentials, this.provider);
authContext.credentials = await makeTempCredentials(
authContext.credentials,
this.credentialFetcher
);
}

const { credentials } = authContext;
Expand Down Expand Up @@ -202,17 +156,9 @@ export class MongoDBAWS extends AuthProvider {
}
}

interface AWSTempCredentials {
AccessKeyId?: string;
SecretAccessKey?: string;
Token?: string;
RoleArn?: string;
Expiration?: Date;
}

async function makeTempCredentials(
credentials: MongoCredentials,
provider?: () => Promise<AWSCredentials>
awsCredentialFetcher: AWSTemporaryCredentialProvider
): Promise<MongoCredentials> {
function makeMongoCredentialsFromAWSTemp(creds: AWSTempCredentials) {
// The AWS session token (creds.Token) may or may not be set.
Expand All @@ -230,62 +176,9 @@ async function makeTempCredentials(
}
});
}
const temporaryCredentials = await awsCredentialFetcher.getCredentials();

// Check if the AWS credential provider from the SDK is present. If not,
// use the old method.
if (provider && !('kModuleError' in MongoDBAWS.credentialProvider)) {
/*
* Creates a credential provider that will attempt to find credentials from the
* following sources (listed in order of precedence):
*
* - Environment variables exposed via process.env
* - SSO credentials from token cache
* - Web identity token credentials
* - Shared credentials and config ini files
* - The EC2/ECS Instance Metadata Service
*/
try {
const creds = await provider();
return makeMongoCredentialsFromAWSTemp({
AccessKeyId: creds.accessKeyId,
SecretAccessKey: creds.secretAccessKey,
Token: creds.sessionToken,
Expiration: creds.expiration
});
} catch (error) {
throw new MongoAWSError(error.message);
}
} else {
// If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
// is set then drivers MUST assume that it was set by an AWS ECS agent
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
return makeMongoCredentialsFromAWSTemp(
await request(`${AWS_RELATIVE_URI}${process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`)
);
}

// Otherwise assume we are on an EC2 instance

// get a token
const token = await request(`${AWS_EC2_URI}/latest/api/token`, {
method: 'PUT',
json: false,
headers: { 'X-aws-ec2-metadata-token-ttl-seconds': 30 }
});

// get role name
const roleName = await request(`${AWS_EC2_URI}/${AWS_EC2_PATH}`, {
json: false,
headers: { 'X-aws-ec2-metadata-token': token }
});

// get temp credentials
const creds = await request(`${AWS_EC2_URI}/${AWS_EC2_PATH}/${roleName}`, {
headers: { 'X-aws-ec2-metadata-token': token }
});

return makeMongoCredentialsFromAWSTemp(creds);
}
return makeMongoCredentialsFromAWSTemp(temporaryCredentials);
}

function deriveRegion(host: string) {
Expand Down
4 changes: 2 additions & 2 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,8 @@ export class MongoAWSError extends MongoRuntimeError {
*
* @public
**/
constructor(message: string) {
super(message);
constructor(message: string, options?: { cause?: Error }) {
super(message, options);
}

override get name(): string {
Expand Down
Loading

0 comments on commit 918fe69

Please sign in to comment.