diff --git a/.evergreen/run-mongodb-aws-ecs-test.sh b/.evergreen/run-mongodb-aws-ecs-test.sh index c43776c037..c80b6d48c5 100755 --- a/.evergreen/run-mongodb-aws-ecs-test.sh +++ b/.evergreen/run-mongodb-aws-ecs-test.sh @@ -13,6 +13,3 @@ source ./.evergreen/prepare-shell.sh # should not run git clone # load node.js source $DRIVERS_TOOLS/.evergreen/init-node-and-npm-env.sh - -# run the tests -npm install aws4 diff --git a/.evergreen/setup-mongodb-aws-auth-tests.sh b/.evergreen/setup-mongodb-aws-auth-tests.sh index 800d116e27..180cd4e1c8 100644 --- a/.evergreen/setup-mongodb-aws-auth-tests.sh +++ b/.evergreen/setup-mongodb-aws-auth-tests.sh @@ -22,7 +22,5 @@ cd $DRIVERS_TOOLS/.evergreen/auth_aws cd $BEFORE -npm install --no-save aws4 - # revert to show test output set -x diff --git a/src/aws4.ts b/src/aws4.ts new file mode 100644 index 0000000000..c34a328b14 --- /dev/null +++ b/src/aws4.ts @@ -0,0 +1,157 @@ +import * as crypto from 'node:crypto'; + +import { type AWSCredentials } from './deps'; + +export type Options = { + path: '/'; + body: string; + host: string; + method: 'POST'; + headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + 'Content-Length': number; + 'X-MongoDB-Server-Nonce': string; + 'X-MongoDB-GS2-CB-Flag': 'n'; + }; + service: string; + region: string; + date?: Date; +}; + +export type SignedHeaders = { + headers: { + Authorization: string; + 'X-Amz-Date': string; + }; +}; + +const getHash = (str: string): string => { + return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); +}; +const getHmacBuffer = (key: string | Uint8Array, str: string): Uint8Array => { + return crypto.createHmac('sha256', key).update(str, 'utf8').digest(); +}; +const getHmacString = (key: Uint8Array, str: string): string => { + return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); +}; + +const convertHeaderValue = (value: string | number) => { + return value.toString().trim().replace(/\s+/g, ' '); +}; + +/** + * This method implements AWS Signature 4 logic for a very specific request format. + * The signing logic is described here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html + */ +export function aws4Sign(options: Options, credentials: AWSCredentials): SignedHeaders { + /** + * From the spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html + * + * Summary of signing steps + * 1. Create a canonical request + * Arrange the contents of your request (host, action, headers, etc.) into a standard canonical format. The canonical request is one of the inputs used to create the string to sign. + * 2. Create a hash of the canonical request + * Hash the canonical request using the same algorithm that you used to create the hash of the payload. The hash of the canonical request is a string of lowercase hexadecimal characters. + * 3. Create a string to sign + * Create a string to sign with the canonical request and extra information such as the algorithm, request date, credential scope, and the hash of the canonical request. + * 4. Derive a signing key + * Use the secret access key to derive the key used to sign the request. + * 5. Calculate the signature + * Perform a keyed hash operation on the string to sign using the derived signing key as the hash key. + * 6. Add the signature to the request + * Add the calculated signature to an HTTP header or to the query string of the request. + */ + + // 1: Create a canonical request + + // Date – The date and time used to sign the request. If not provided, use the current date. + const date = options.date || new Date(); + // RequestDateTime – The date and time used in the credential scope. This value is the current UTC time in ISO 8601 format (for example, 20130524T000000Z). + const requestDateTime = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); + // RequestDate – The date used in the credential scope. This value is the current UTC date in YYYYMMDD format (for example, 20130524). + const requestDate = requestDateTime.substring(0, 8); + // Method – The HTTP request method. For us, this is always 'POST'. + const method = options.method; + // CanonicalUri – The URI-encoded version of the absolute path component URI, starting with the / that follows the domain name and up to the end of the string + // For our requests, this is always '/' + const canonicalUri = options.path; + // CanonicalQueryString – The URI-encoded query string parameters. For our requests, there are no query string parameters, so this is always an empty string. + const canonicalQuerystring = ''; + + // CanonicalHeaders – A list of request headers with their values. Individual header name and value pairs are separated by the newline character ("\n"). + // All of our known/expected headers are included here, there are no extra headers. + const headers = new Headers({ + 'content-length': convertHeaderValue(options.headers['Content-Length']), + 'content-type': convertHeaderValue(options.headers['Content-Type']), + host: convertHeaderValue(options.host), + 'x-amz-date': convertHeaderValue(requestDateTime), + 'x-mongodb-gs2-cb-flag': convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag']), + 'x-mongodb-server-nonce': convertHeaderValue(options.headers['X-MongoDB-Server-Nonce']) + }); + // If session token is provided, include it in the headers + if ('sessionToken' in credentials && credentials.sessionToken) { + headers.append('x-amz-security-token', convertHeaderValue(credentials.sessionToken)); + } + // Canonical headers are lowercased and sorted. + const canonicalHeaders = Array.from(headers.entries()) + .map(([key, value]) => `${key.toLowerCase()}:${value}`) + .sort() + .join('\n'); + const canonicalHeaderNames = Array.from(headers.keys()).map(header => header.toLowerCase()); + // SignedHeaders – An alphabetically sorted, semicolon-separated list of lowercase request header names. + const signedHeaders = canonicalHeaderNames.sort().join(';'); + + // HashedPayload – A string created using the payload in the body of the HTTP request as input to a hash function. This string uses lowercase hexadecimal characters. + const hashedPayload = getHash(options.body); + + // CanonicalRequest – A string that includes the above elements, separated by newline characters. + const canonicalRequest = [ + method, + canonicalUri, + canonicalQuerystring, + canonicalHeaders + '\n', + signedHeaders, + hashedPayload + ].join('\n'); + + // 2. Create a hash of the canonical request + // HashedCanonicalRequest – A string created by using the canonical request as input to a hash function. + const hashedCanonicalRequest = getHash(canonicalRequest); + + // 3. Create a string to sign + // Algorithm – The algorithm used to create the hash of the canonical request. For SigV4, use AWS4-HMAC-SHA256. + const algorithm = 'AWS4-HMAC-SHA256'; + // CredentialScope – The credential scope, which restricts the resulting signature to the specified Region and service. + // Has the following format: YYYYMMDD/region/service/aws4_request. + const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; + // StringToSign – A string that includes the above elements, separated by newline characters. + const stringToSign = [algorithm, requestDateTime, credentialScope, hashedCanonicalRequest].join( + '\n' + ); + + // 4. Derive a signing key + // To derive a signing key for SigV4, perform a succession of keyed hash operations (HMAC) on the request date, Region, and service, with your AWS secret access key as the key for the initial hashing operation. + const dateKey = getHmacBuffer('AWS4' + credentials.secretAccessKey, requestDate); + const dateRegionKey = getHmacBuffer(dateKey, options.region); + const dateRegionServiceKey = getHmacBuffer(dateRegionKey, options.service); + const signingKey = getHmacBuffer(dateRegionServiceKey, 'aws4_request'); + + // 5. Calculate the signature + const signature = getHmacString(signingKey, stringToSign); + + // 6. Add the signature to the request + // Calculate the Authorization header + const authorizationHeader = [ + 'AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credentialScope, + 'SignedHeaders=' + signedHeaders, + 'Signature=' + signature + ].join(', '); + + // Return the calculated headers + return { + headers: { + Authorization: authorizationHeader, + 'X-Amz-Date': requestDateTime + } + }; +} diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 2736564065..458c42f227 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -1,6 +1,6 @@ +import { aws4Sign } from '../../aws4'; import type { Binary, BSONSerializeOptions } from '../../bson'; import * as BSON from '../../bson'; -import { aws4 } from '../../deps'; import { MongoCompatibilityError, MongoMissingCredentialsError, @@ -45,11 +45,6 @@ export class MongoDBAWS extends AuthProvider { throw new MongoMissingCredentialsError('AuthContext must provide credentials.'); } - if ('kModuleError' in aws4) { - throw aws4['kModuleError']; - } - const { sign } = aws4; - if (maxWireVersion(connection) < 9) { throw new MongoCompatibilityError( 'MONGODB-AWS authentication requires MongoDB version 4.4 or later' @@ -68,13 +63,10 @@ export class MongoDBAWS extends AuthProvider { // Allow the user to specify an AWS session token for authentication with temporary credentials. const sessionToken = credentials.mechanismProperties.AWS_SESSION_TOKEN; - // If all three defined, include sessionToken, else include username and pass, else no credentials - const awsCredentials = - accessKeyId && secretAccessKey && sessionToken - ? { accessKeyId, secretAccessKey, sessionToken } - : accessKeyId && secretAccessKey - ? { accessKeyId, secretAccessKey } - : undefined; + // If all three defined, include sessionToken, else only include username and pass + const awsCredentials = sessionToken + ? { accessKeyId, secretAccessKey, sessionToken } + : { accessKeyId, secretAccessKey }; const db = credentials.source; const nonce = await randomBytes(32); @@ -114,7 +106,7 @@ export class MongoDBAWS extends AuthProvider { } const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const options = sign( + const signed = aws4Sign( { method: 'POST', host, @@ -133,8 +125,8 @@ export class MongoDBAWS extends AuthProvider { ); const payload: AWSSaslContinuePayload = { - a: options.headers.Authorization, - d: options.headers['X-Amz-Date'] + a: signed.headers.Authorization, + d: signed.headers['X-Amz-Date'] }; if (sessionToken) { diff --git a/src/deps.ts b/src/deps.ts index e9f4a42e39..300d1daed1 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -89,7 +89,7 @@ export interface AWSCredentials { expiration?: Date; } -type CredentialProvider = { +export type CredentialProvider = { fromNodeProviderChain( this: void, options: { clientConfig: { region: string } } @@ -203,66 +203,6 @@ export function getSocks(): SocksLib | { kModuleError: MongoMissingDependencyErr } } -interface AWS4 { - /** - * Created these inline types to better assert future usage of this API - * @param options - options for request - * @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y - */ - sign( - this: void, - options: { - path: '/'; - body: string; - host: string; - method: 'POST'; - headers: { - 'Content-Type': 'application/x-www-form-urlencoded'; - 'Content-Length': number; - 'X-MongoDB-Server-Nonce': string; - 'X-MongoDB-GS2-CB-Flag': 'n'; - }; - service: string; - region: string; - }, - credentials: - | { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; - } - | { - accessKeyId: string; - secretAccessKey: string; - } - | undefined - ): { - headers: { - Authorization: string; - 'X-Amz-Date': string; - }; - }; -} - -export const aws4: AWS4 | { kModuleError: MongoMissingDependencyError } = loadAws4(); - -function loadAws4() { - let aws4: AWS4 | { kModuleError: MongoMissingDependencyError }; - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - aws4 = require('aws4'); - } catch (error) { - aws4 = makeErrorModule( - new MongoMissingDependencyError( - 'Optional module `aws4` not found. Please install it to enable AWS authentication', - { cause: error, dependencyName: 'aws4' } - ) - ); - } - - return aws4; -} - /** A utility function to get the instance of mongodb-client-encryption, if it exists. */ export function getMongoDBClientEncryption(): | typeof import('mongodb-client-encryption') diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index 3dff1d642a..b3fe374c4f 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 { + type AWSCredentials, type CommandOptions, type Document, MongoAWSError, @@ -16,6 +17,7 @@ import { MongoMissingDependencyError, MongoServerError } from '../../../src'; +import { aws4Sign } from '../../../src/aws4'; import { refreshKMSCredentials } from '../../../src/client-side-encryption/providers'; import { AWSSDKCredentialProvider } from '../../../src/cmap/auth/aws_temporary_credentials'; import { MongoDBAWS } from '../../../src/cmap/auth/mongodb_aws'; @@ -38,44 +40,6 @@ describe('MONGODB-AWS', function () { await client?.close(); }); - context('when the AWS SDK is not present', function () { - beforeEach(function () { - AWSSDKCredentialProvider.awsSDK['kModuleError'] = new MongoMissingDependencyError( - 'Missing dependency @aws-sdk/credential-providers', - { - cause: new Error(), - dependencyName: '@aws-sdk/credential-providers' - } - ); - }); - - afterEach(function () { - delete AWSSDKCredentialProvider.awsSDK['kModuleError']; - }); - - describe('when attempting AWS auth', function () { - it('throws an error', async function () { - client = this.configuration.newClient(process.env.MONGODB_URI); // use the URI built by the test environment - - const result = await client - .db('aws') - .collection('aws_test') - .estimatedDocumentCount() - .catch(e => e); - - // TODO(NODE-7046): Remove branch when removing support for AWS credentials in URI. - // The drivers tools scripts put the credentials in the URI currently for some environments, - // this will need to change when doing the DRIVERS-3131 work. - if (!client.options.credentials.username) { - expect(result).to.be.instanceof(MongoAWSError); - expect(result.message).to.match(/credential-providers/); - } else { - expect(result).to.equal(0); - } - }); - }); - }); - context('when the AWS SDK is present', function () { it('should authorize when successfully authenticated', async function () { client = this.configuration.newClient(process.env.MONGODB_URI); // use the URI built by the test environment @@ -263,6 +227,104 @@ describe('MONGODB-AWS', function () { expect(timeTaken).to.be.below(12000); }); }); + + // This test verifies that our AWS SigV4 signing works correctly with real AWS credentials. + // This is done by calculating a signature, then using it to make a real request to the AWS STS service. + // There are two tests here: one for permanent credentials, and one for session credentials. + // Permanent credentials are tested by Evergreen task "aws-latest-auth-test-run-aws-auth-test-with-aws-credentials-as-environment-variables" + // Session credentials are tested by Evergreen task "aws-latest-auth-test-run-aws-auth-test-with-aws-credentials-and-session-token-as-environment-variables" + describe('AwsSigV4 works with SDK credentials', function () { + let credentials: AWSCredentials; + + beforeEach(async function () { + const sdk = AWSSDKCredentialProvider.awsSDK; + if ('kModuleError' in sdk) { + this.skipReason = 'AWS SDK not installed'; + this.skip(); + } else { + credentials = await sdk.fromNodeProviderChain()(); + } + }); + + const testSigning = async creds => { + const host = 'sts.amazonaws.com'; + const body = 'Action=GetCallerIdentity&Version=2011-06-15'; + const headers: { + 'Content-Type': 'application/x-www-form-urlencoded'; + 'Content-Length': number; + 'X-MongoDB-Server-Nonce': string; + 'X-MongoDB-GS2-CB-Flag': 'n'; + } = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': body.length, + 'X-MongoDB-Server-Nonce': 'fakenonce', + 'X-MongoDB-GS2-CB-Flag': 'n' + }; + const signed = aws4Sign( + { + method: 'POST', + host, + path: '/', + region: 'us-east-1', + service: 'sts', + headers: headers, + body + }, + creds + ); + + const authorization = signed.headers.Authorization; + const xAmzDate = signed.headers['X-Amz-Date']; + + const fetchHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + fetchHeaders.append(key, value.toString()); + } + if (credentials && credentials.sessionToken) { + fetchHeaders.append('X-Amz-Security-Token', credentials.sessionToken); + } + fetchHeaders.append('Authorization', authorization); + fetchHeaders.append('X-Amz-Date', xAmzDate); + const response = await fetch('https://sts.amazonaws.com', { + method: 'POST', + headers: fetchHeaders, + body + }); + const text = await response.text(); + + expect(response.status).to.equal(200); + expect(response.statusText).to.equal('OK'); + expect(text).to.match( + // + ); + }; + + describe('when using premanent credentials', function () { + beforeEach(async function () { + if ('sessionToken' in credentials && credentials.sessionToken) { + this.skipReason = 'permanent credentials not found in the environment'; + this.skip(); + } + }); + + it('signs requests correctly', async function () { + await testSigning(credentials); + }); + }); + + describe('when using session credentials', function () { + beforeEach(async function () { + if (!('sessionToken' in credentials) || !credentials.sessionToken) { + this.skipReason = 'session credentials not found in the environment'; + this.skip(); + } + }); + + it('signs requests correctly', async function () { + await testSigning(credentials); + }); + }); + }); }); describe('when using AssumeRoleWithWebIdentity', () => { diff --git a/test/unit/assorted/optional_require.test.ts b/test/unit/assorted/optional_require.test.ts index 0a729d6fd4..5dc579ee30 100644 --- a/test/unit/assorted/optional_require.test.ts +++ b/test/unit/assorted/optional_require.test.ts @@ -4,7 +4,6 @@ import { resolve } from 'path'; import { AuthContext } from '../../../src/cmap/auth/auth_provider'; import { GSSAPI } from '../../../src/cmap/auth/gssapi'; -import { MongoDBAWS } from '../../../src/cmap/auth/mongodb_aws'; import { compress } from '../../../src/cmap/wire_protocol/compression'; import { MongoMissingDependencyError } from '../../../src/error'; import { HostAddress } from '../../../src/utils'; @@ -51,20 +50,4 @@ describe('optionalRequire', function () { expect(error).to.be.instanceOf(MongoMissingDependencyError); }); }); - - describe('aws4', function () { - it('should error if not installed', async function () { - const moduleName = 'aws4'; - if (moduleExistsSync(moduleName)) { - return this.skip(); - } - const mdbAWS = new MongoDBAWS(); - - const error = await mdbAWS - .auth(new AuthContext({ hello: { maxWireVersion: 9 } }, true, null)) - .catch(error => error); - - expect(error).to.be.instanceOf(MongoMissingDependencyError); - }); - }); }); diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts new file mode 100644 index 0000000000..4c3d7fb2e3 --- /dev/null +++ b/test/unit/aws4.test.ts @@ -0,0 +1,73 @@ +import { expect } from 'chai'; + +import { aws4Sign, type Options } from '../../src/aws4'; + +describe('Verify AWS4 signature generation', () => { + const date = new Date('2025-12-15T12:34:56Z'); + const awsCredentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' + }; + const awsSessionCredentials = { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYexamplekey', + sessionToken: 'AQoDYXdzEJ' + }; + const host = 'sts.amazonaws.com'; + const body = 'Action=GetCallerIdentity&Version=2011-06-15'; + const request: Options = { + method: 'POST', + host, + path: '/', + region: 'us-east-1', + service: 'sts', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': body.length, + 'X-MongoDB-Server-Nonce': 'fakenonce', + 'X-MongoDB-GS2-CB-Flag': 'n' + }, + body, + date + }; + + it('should generate correct credentials for permanent credentials', () => { + const signed = aws4Sign(request, awsCredentials); + + expect(signed.headers['X-Amz-Date']).to.exist; + expect(signed.headers['X-Amz-Date']).to.equal('20251215T123456Z'); + expect(signed.headers['Authorization']).to.exist; + expect(signed.headers['Authorization']).to.equal( + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20251215/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce, Signature=48a66f9fc76829002a7a7ac5b92e4089395d9b88ea7d417ab146949b90eeab08' + ); + + // Uncomment the following lines if you want to compare with the old aws4 library. + // Remember to import aws4 at the top of the file, like this: import * as aws4sign from 'aws4'; + + // const oldSigned = aws4sign.sign(request, awsCredentials); + // expect(oldSigned.headers['X-Amz-Date']).to.exist; + // expect(oldSigned.headers['X-Amz-Date']).to.equal(signed.headers['X-Amz-Date']); + // expect(oldSigned.headers['Authorization']).to.exist; + // expect(oldSigned.headers['Authorization']).to.equal(signed.headers['Authorization']); + }); + + it('should generate correct credentials for session credentials', () => { + const signed = aws4Sign(request, awsSessionCredentials); + + expect(signed.headers['X-Amz-Date']).to.exist; + expect(signed.headers['X-Amz-Date']).to.equal('20251215T123456Z'); + expect(signed.headers['Authorization']).to.exist; + expect(signed.headers['Authorization']).to.equal( + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20251215/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-security-token;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce, Signature=bbcb06e2feb8651dced329789743ba283f92ef1302d34a7398cb1d35808a1a66' + ); + + // Uncomment the following lines if you want to compare with the old aws4 library. + // Remember to import aws4 at the top of the file, like this: import * as aws4sign from 'aws4'; + + // const oldSigned = aws4sign.sign(request, awsSessionCredentials); + // expect(oldSigned.headers['X-Amz-Date']).to.exist; + // expect(oldSigned.headers['X-Amz-Date']).to.equal(signed.headers['X-Amz-Date']); + // expect(oldSigned.headers['Authorization']).to.exist; + // expect(oldSigned.headers['Authorization']).to.equal(signed.headers['Authorization']); + }); +});