diff --git a/src/cmap/auth/aws_temporary_credentials.ts b/src/cmap/auth/aws_temporary_credentials.ts new file mode 100644 index 0000000000..dd474deaf7 --- /dev/null +++ b/src/cmap/auth/aws_temporary_credentials.ts @@ -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; + private static _awsSDK: ReturnType; + 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; + /** + * 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 { + 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 { + /* + * 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 { + // 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; + } +} diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 213d2b0547..8fd1a320b8 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -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, @@ -55,40 +33,13 @@ interface AWSSaslContinuePayload { } export class MongoDBAWS extends AuthProvider { - static credentialProvider: ReturnType; - provider?: () => Promise; - + 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 { @@ -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; @@ -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 + awsCredentialFetcher: AWSTemporaryCredentialProvider ): Promise { function makeMongoCredentialsFromAWSTemp(creds: AWSTempCredentials) { // The AWS session token (creds.Token) may or may not be set. @@ -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) { diff --git a/src/error.ts b/src/error.ts index 6f91f24634..ffb85435e1 100644 --- a/src/error.ts +++ b/src/error.ts @@ -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 { diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index d3f6933c35..a96ed91d53 100644 --- a/test/integration/auth/mongodb_aws.test.ts +++ b/test/integration/auth/mongodb_aws.test.ts @@ -6,6 +6,7 @@ import { performance } from 'perf_hooks'; import * as sinon from 'sinon'; import { + AWSTemporaryCredentialProvider, MongoAWSError, type MongoClient, MongoDBAWS, @@ -268,7 +269,8 @@ describe('MONGODB-AWS', function () { numberOfFromNodeProviderChainCalls = 0; - MongoDBAWS.credentialProvider = { + // @ts-expect-error We intentionally access a protected variable. + AWSTemporaryCredentialProvider._awsSDK = { fromNodeProviderChain(...args) { calledArguments = args; numberOfFromNodeProviderChainCalls += 1; @@ -289,7 +291,8 @@ describe('MONGODB-AWS', function () { if (typeof storedEnv.AWS_STS_REGIONAL_ENDPOINTS === 'string') { process.env.AWS_REGION = storedEnv.AWS_REGION; } - MongoDBAWS.credentialProvider = credentialProvider; + // @ts-expect-error We intentionally access a protected variable. + AWSTemporaryCredentialProvider._awsSDK = credentialProvider; calledArguments = []; }); diff --git a/test/mongodb.ts b/test/mongodb.ts index d2cbd3e16c..b043818115 100644 --- a/test/mongodb.ts +++ b/test/mongodb.ts @@ -101,6 +101,7 @@ export * from '../src/bulk/ordered'; export * from '../src/bulk/unordered'; export * from '../src/change_stream'; export * from '../src/cmap/auth/auth_provider'; +export * from '../src/cmap/auth/aws_temporary_credentials'; export * from '../src/cmap/auth/gssapi'; export * from '../src/cmap/auth/mongo_credentials'; export * from '../src/cmap/auth/mongocr';