Skip to content

refactor(NODE-6040): extract aws temporary credential acquisition logic into a standalone module #4050

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

Merged
merged 7 commits into from
Mar 25, 2024
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
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