From 7cc115669255af0128cd3b378d42f580d807935d Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 11 Dec 2025 14:11:11 -0800 Subject: [PATCH 1/7] sigv4 implementation --- .evergreen/run-mongodb-aws-ecs-test.sh | 3 - .evergreen/setup-mongodb-aws-auth-tests.sh | 2 - src/aws4.ts | 207 ++++++++++++++++++++ src/cmap/auth/mongodb_aws.ts | 9 +- src/deps.ts | 60 ------ test/unit/assorted/optional_require.test.ts | 17 -- 6 files changed, 209 insertions(+), 89 deletions(-) create mode 100644 src/aws4.ts diff --git a/.evergreen/run-mongodb-aws-ecs-test.sh b/.evergreen/run-mongodb-aws-ecs-test.sh index c43776c037e..c80b6d48c5b 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 800d116e276..180cd4e1c8b 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 00000000000..32174673f63 --- /dev/null +++ b/src/aws4.ts @@ -0,0 +1,207 @@ +import * as crypto from 'node:crypto'; +import * as queryString from 'node:querystring'; + +export 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 function aws4Sign( + 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; + }; +} { + let path: string; + let query: queryString.ParsedUrlQuery | undefined; + + const encode = (str: string) => { + const encoded = encodeURIComponent(str); + const replaced = encoded.replace(/[!'()*]/g, function (c) { + return '%' + c.charCodeAt(0).toString(16).toUpperCase(); + }); + return replaced; + }; + + const queryIndex = options.path.indexOf('?'); + if (queryIndex < 0) { + path = options.path; + query = undefined; + } else { + path = options.path.slice(0, queryIndex); + query = queryString.parse(options.path.slice(queryIndex + 1)); + } + + let canonicalQuerystring = ''; + if (query) { + const isS3 = options.service === 's3'; + const useFirstArrayValue = isS3; + // const decodeSlashesInPath = isS3; + // const decodePath = isS3; + // const normalizePath = !isS3; + const queryStrings: string[] = []; + const sortedQueryKeys = Object.keys(query).sort(); + for (const key of sortedQueryKeys) { + if (!key) { + continue; + } + + const encodedKey = encode(key); + let value: string | string[] | undefined = query[key]; + if (Array.isArray(value)) { + let values: string[] = value; + if (useFirstArrayValue) { + values = [value[0]]; + } + + for (const item of values) { + const encodedValue = encode(item); + queryStrings.push(`${encodedKey}=${encodedValue}`); + } + } else { + value = value ?? ''; + const encodedValue = encode(value); + queryStrings.push(`${encodedKey}=${encodedValue}`); + } + } + canonicalQuerystring = queryStrings.join('&'); + } + + const convertHeaderValue = (value: string | number) => { + return value.toString().trim().replace(/\s+/g, ' '); + }; + const headers: string[] = [ + `content-length:${convertHeaderValue(options.headers['Content-Length'])}\n`, + `content-type:${convertHeaderValue(options.headers['Content-Type'])}\n`, + `x-mongodb-gs2-cb-flag:${convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag'])}\n`, + `x-mongodb-server-nonce:${convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])}\n` + ]; + const canonicalHeaders = headers.sort().join('\n'); + + const signedHeaders = 'content-length;content-type;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce'; + + const getHash = (str: string): string => { + return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); + }; + const getHmac = (key: string, str: string): string => { + return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); + }; + const hashedPayload = getHash(options.body || ''); + + const canonicalUri = path; + const canonicalRequest = [ + options.method, + canonicalUri, + canonicalQuerystring, + canonicalHeaders, + signedHeaders, + hashedPayload + ].join('\n'); + + const canonicRequestHash = getHash(canonicalRequest); + const requestDateTime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); + const requestDate = requestDateTime.substring(0, 8); + const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; + + const stringToSign = [ + 'AWS4-HMAC-SHA256', + requestDateTime, + credentialScope, + canonicRequestHash + ].join('\n'); + + const getEnvCredentials = () => { + const env = process.env; + return { + accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, + sessionToken: env.AWS_SESSION_TOKEN + }; + }; + const creds = credentials || getEnvCredentials(); + const dateKey = getHmac('AWS4' + creds.secretAccessKey, requestDate); + const dateRegionKey = getHmac(dateKey, options.region); + const dateRegionServiceKey = getHmac(dateRegionKey, options.service); + const signingKey = getHmac(dateRegionServiceKey, 'aws4_request'); + const signature = getHmac(signingKey, stringToSign); + + const authorizationHeader = [ + 'AWS4-HMAC-SHA256 Credential=' + creds.accessKeyId + '/' + credentialScope, + 'SignedHeaders=' + signedHeaders, + 'Signature=' + signature + ].join(', '); + + return { + headers: { + Authorization: authorizationHeader, + 'X-Amz-Date': requestDateTime + } + }; +} + +// export const aws4: AWS4 = { +// sign: aws4Sign +// }; diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 27365640651..3b3555d7318 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' @@ -114,7 +109,7 @@ export class MongoDBAWS extends AuthProvider { } const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const options = sign( + const options = aws4Sign( { method: 'POST', host, diff --git a/src/deps.ts b/src/deps.ts index e9f4a42e39f..f4c0b0f9cad 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -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/unit/assorted/optional_require.test.ts b/test/unit/assorted/optional_require.test.ts index 0a729d6fd4f..5dc579ee304 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); - }); - }); }); From 811d453f1bbbd125a90183a1e9db1f4ae0fb6e3c Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 12 Dec 2025 13:49:11 -0800 Subject: [PATCH 2/7] fix issues and add a test, and a simple way to run it --- etc/aws-test.sh | 17 +++ src/aws4.ts | 144 ++++++++-------------- test/integration/auth/aws.test.ts | 105 ++++++++++++++++ test/integration/auth/mongodb_aws.test.ts | 38 ------ 4 files changed, 172 insertions(+), 132 deletions(-) create mode 100755 etc/aws-test.sh create mode 100644 test/integration/auth/aws.test.ts diff --git a/etc/aws-test.sh b/etc/aws-test.sh new file mode 100755 index 00000000000..8a8c8d140b3 --- /dev/null +++ b/etc/aws-test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +cd $DRIVERS_TOOLS/.evergreen/auth_aws + +. ./activate-authawsvenv.sh + +# Test with permanent credentials +. aws_setup.sh env-creds +unset MONGODB_URI +echo "AWS_SESSION_TOKEN is set to '${AWS_SESSION_TOKEN-NOT SET}'" +npm run check:test -- --grep "AwsSigV4" + +# Test with session credentials +. aws_setup.sh session-creds +unset MONGODB_URI +echo "AWS_SESSION_TOKEN is set to '${AWS_SESSION_TOKEN-NOT SET}'" +npm run check:test -- --grep "AwsSigV4" diff --git a/src/aws4.ts b/src/aws4.ts index 32174673f63..1b3540200b3 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -1,5 +1,4 @@ import * as crypto from 'node:crypto'; -import * as queryString from 'node:querystring'; export interface AWS4 { /** @@ -42,6 +41,29 @@ export interface AWS4 { }; } +const getHash = (str: string): string => { + return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); +}; +const getHmacArray = (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 getEnvCredentials = () => { + const env = process.env; + return { + accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, + sessionToken: env.AWS_SESSION_TOKEN + }; +}; + +const convertHeaderValue = (value: string | number) => { + return value.toString().trim().replace(/\s+/g, ' '); +}; + export function aws4Sign( this: void, options: { @@ -75,118 +97,56 @@ export function aws4Sign( 'X-Amz-Date': string; }; } { - let path: string; - let query: queryString.ParsedUrlQuery | undefined; - - const encode = (str: string) => { - const encoded = encodeURIComponent(str); - const replaced = encoded.replace(/[!'()*]/g, function (c) { - return '%' + c.charCodeAt(0).toString(16).toUpperCase(); - }); - return replaced; - }; - - const queryIndex = options.path.indexOf('?'); - if (queryIndex < 0) { - path = options.path; - query = undefined; - } else { - path = options.path.slice(0, queryIndex); - query = queryString.parse(options.path.slice(queryIndex + 1)); - } - - let canonicalQuerystring = ''; - if (query) { - const isS3 = options.service === 's3'; - const useFirstArrayValue = isS3; - // const decodeSlashesInPath = isS3; - // const decodePath = isS3; - // const normalizePath = !isS3; - const queryStrings: string[] = []; - const sortedQueryKeys = Object.keys(query).sort(); - for (const key of sortedQueryKeys) { - if (!key) { - continue; - } - - const encodedKey = encode(key); - let value: string | string[] | undefined = query[key]; - if (Array.isArray(value)) { - let values: string[] = value; - if (useFirstArrayValue) { - values = [value[0]]; - } + const method = options.method; + const canonicalUri = options.path; + const canonicalQuerystring = ''; + const creds = credentials || getEnvCredentials(); - for (const item of values) { - const encodedValue = encode(item); - queryStrings.push(`${encodedKey}=${encodedValue}`); - } - } else { - value = value ?? ''; - const encodedValue = encode(value); - queryStrings.push(`${encodedKey}=${encodedValue}`); - } - } - canonicalQuerystring = queryStrings.join('&'); - } + const date = new Date(); + const requestDateTime = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); + const requestDate = requestDateTime.substring(0, 8); - const convertHeaderValue = (value: string | number) => { - return value.toString().trim().replace(/\s+/g, ' '); - }; const headers: string[] = [ - `content-length:${convertHeaderValue(options.headers['Content-Length'])}\n`, - `content-type:${convertHeaderValue(options.headers['Content-Type'])}\n`, - `x-mongodb-gs2-cb-flag:${convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag'])}\n`, - `x-mongodb-server-nonce:${convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])}\n` + `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 ('sessionToken' in creds && creds.sessionToken) { + headers.push(`x-amz-security-token:${convertHeaderValue(creds.sessionToken)}`); + } const canonicalHeaders = headers.sort().join('\n'); + const canonicalHeaderNames = headers.map(header => header.split(':', 2)[0].toLowerCase()); + const signedHeaders = canonicalHeaderNames.sort().join(';'); - const signedHeaders = 'content-length;content-type;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce'; - - const getHash = (str: string): string => { - return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); - }; - const getHmac = (key: string, str: string): string => { - return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); - }; const hashedPayload = getHash(options.body || ''); - const canonicalUri = path; const canonicalRequest = [ - options.method, + method, canonicalUri, canonicalQuerystring, - canonicalHeaders, + canonicalHeaders + '\n', signedHeaders, hashedPayload ].join('\n'); - const canonicRequestHash = getHash(canonicalRequest); - const requestDateTime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); - const requestDate = requestDateTime.substring(0, 8); + const canonicalRequestHash = getHash(canonicalRequest); const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; const stringToSign = [ 'AWS4-HMAC-SHA256', requestDateTime, credentialScope, - canonicRequestHash + canonicalRequestHash ].join('\n'); - const getEnvCredentials = () => { - const env = process.env; - return { - accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, - sessionToken: env.AWS_SESSION_TOKEN - }; - }; - const creds = credentials || getEnvCredentials(); - const dateKey = getHmac('AWS4' + creds.secretAccessKey, requestDate); - const dateRegionKey = getHmac(dateKey, options.region); - const dateRegionServiceKey = getHmac(dateRegionKey, options.service); - const signingKey = getHmac(dateRegionServiceKey, 'aws4_request'); - const signature = getHmac(signingKey, stringToSign); + const dateKey = getHmacArray('AWS4' + creds.secretAccessKey, requestDate); + const dateRegionKey = getHmacArray(dateKey, options.region); + const dateRegionServiceKey = getHmacArray(dateRegionKey, options.service); + const signingKey = getHmacArray(dateRegionServiceKey, 'aws4_request'); + const signature = getHmacString(signingKey, stringToSign); const authorizationHeader = [ 'AWS4-HMAC-SHA256 Credential=' + creds.accessKeyId + '/' + credentialScope, @@ -201,7 +161,3 @@ export function aws4Sign( } }; } - -// export const aws4: AWS4 = { -// sign: aws4Sign -// }; diff --git a/test/integration/auth/aws.test.ts b/test/integration/auth/aws.test.ts new file mode 100644 index 00000000000..7042b33bd6c --- /dev/null +++ b/test/integration/auth/aws.test.ts @@ -0,0 +1,105 @@ +import * as process from 'node:process'; + +import { expect } from 'chai'; + +import { aws4Sign } from '../../../src/aws4'; + +// 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. +// To run this test, simply run `./etc/aws-test.sh`. + +describe('AwsSigV4', function () { + beforeEach(function () { + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + this.skipReason = 'AWS credentials are not present in the environment'; + this.skip(); + } + }); + + const testSigning = async credentials => { + 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 options = aws4Sign( + { + method: 'POST', + host, + path: '/', + region: 'us-east-1', + service: 'sts', + headers: headers, + body + }, + credentials + ); + + const authorization = options.headers.Authorization; + const xAmzDate = options.headers['X-Amz-Date']; + + const fetchHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + fetchHeaders.append(key, value.toString()); + } + if (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 + }); + expect(response.status).to.equal(200); + expect(response.statusText).to.equal('OK'); + const text = await response.text(); + expect(text).to.match( + // + ); + }; + + describe('AWS4 signs requests with AWS permanent env vars', function () { + before(function () { + if (process.env.AWS_SESSION_TOKEN) { + this.skipReason = 'Skipping permanent credentials test because session token is set'; + this.skip(); + } + }); + + it('AWS4 signs requests with AWS permanent env vars', async () => { + const awsCredentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY + }; + await testSigning(awsCredentials); + }); + }); + + describe('AWS4 signs requests with AWS session env vars', function () { + before(function () { + if (!process.env.AWS_SESSION_TOKEN) { + this.skipReason = 'Skipping session credentials test because session token is not set'; + this.skip(); + } + }); + + it('AWS4 signs requests with AWS session env vars', async () => { + const awsCredentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + sessionToken: process.env.AWS_SESSION_TOKEN + }; + await testSigning(awsCredentials); + }); + }); +}); diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index 3dff1d642a5..ee35a16ea10 100644 --- a/test/integration/auth/mongodb_aws.test.ts +++ b/test/integration/auth/mongodb_aws.test.ts @@ -38,44 +38,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 From a0ba1ec0155adf7a9e20ebb814d658b54fddcec2 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Mon, 15 Dec 2025 10:34:30 -0800 Subject: [PATCH 3/7] added unit tests for the signing logic, added comments about how to compare the output with the old aws4 library --- etc/aws-test.sh | 7 ++ src/aws4.ts | 104 +++++++----------- src/cmap/auth/mongodb_aws.ts | 6 +- .../auth/{aws.test.ts => aws4.test.ts} | 27 ++++- test/unit/aws4.test.ts | 93 ++++++++++++++++ 5 files changed, 166 insertions(+), 71 deletions(-) rename test/integration/auth/{aws.test.ts => aws4.test.ts} (81%) create mode 100644 test/unit/aws4.test.ts diff --git a/etc/aws-test.sh b/etc/aws-test.sh index 8a8c8d140b3..72840c099eb 100755 --- a/etc/aws-test.sh +++ b/etc/aws-test.sh @@ -15,3 +15,10 @@ npm run check:test -- --grep "AwsSigV4" unset MONGODB_URI echo "AWS_SESSION_TOKEN is set to '${AWS_SESSION_TOKEN-NOT SET}'" npm run check:test -- --grep "AwsSigV4" + +# Test with missing credentials +unset MONGODB_URI +unset AWS_ACCESS_KEY_ID +unset AWS_SECRET_ACCESS_KEY +unset AWS_SESSION_TOKEN +npm run check:test -- --grep "AwsSigV4" diff --git a/src/aws4.ts b/src/aws4.ts index 1b3540200b3..913bbe73a40 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -1,5 +1,39 @@ import * as crypto from 'node:crypto'; +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 AwsSessionCredentials = { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; +}; + +export type AwsLongtermCredentials = { + accessKeyId: string; + secretAccessKey: string; +}; + +export type SignedHeaders = { + headers: { + Authorization: string; + 'X-Amz-Date': string; + }; +}; + export interface AWS4 { /** * Created these inline types to better assert future usage of this API @@ -8,37 +42,9 @@ export interface AWS4 { */ 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; - }; - }; + options: Options, + credentials: AwsSessionCredentials | AwsLongtermCredentials | undefined + ): SignedHeaders; } const getHash = (str: string): string => { @@ -66,43 +72,15 @@ const convertHeaderValue = (value: string | number) => { export function aws4Sign( 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; - }; -} { + options: Options, + credentials: AwsSessionCredentials | AwsLongtermCredentials | undefined +): SignedHeaders { const method = options.method; const canonicalUri = options.path; const canonicalQuerystring = ''; const creds = credentials || getEnvCredentials(); - const date = new Date(); + const date = options.date || new Date(); const requestDateTime = date.toISOString().replace(/[:-]|\.\d{3}/g, ''); const requestDate = requestDateTime.substring(0, 8); diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 3b3555d7318..0cb0b5201fe 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -109,7 +109,7 @@ export class MongoDBAWS extends AuthProvider { } const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const options = aws4Sign( + const signed = aws4Sign( { method: 'POST', host, @@ -128,8 +128,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/test/integration/auth/aws.test.ts b/test/integration/auth/aws4.test.ts similarity index 81% rename from test/integration/auth/aws.test.ts rename to test/integration/auth/aws4.test.ts index 7042b33bd6c..f85e4dad2b6 100644 --- a/test/integration/auth/aws.test.ts +++ b/test/integration/auth/aws4.test.ts @@ -30,7 +30,7 @@ describe('AwsSigV4', function () { 'X-MongoDB-Server-Nonce': 'fakenonce', 'X-MongoDB-GS2-CB-Flag': 'n' }; - const options = aws4Sign( + const signed = aws4Sign( { method: 'POST', host, @@ -43,8 +43,8 @@ describe('AwsSigV4', function () { credentials ); - const authorization = options.headers.Authorization; - const xAmzDate = options.headers['X-Amz-Date']; + const authorization = signed.headers.Authorization; + const xAmzDate = signed.headers['X-Amz-Date']; const fetchHeaders = new Headers(); for (const [key, value] of Object.entries(headers)) { @@ -68,6 +68,23 @@ describe('AwsSigV4', function () { ); }; + describe('AWS4 signs requests with missing AWS env vars', function () { + before(function () { + if ( + process.env.AWS_ACCESS_KEY_ID || + process.env.AWS_SECRET_ACCESS_KEY || + process.env.AWS_SESSION_TOKEN + ) { + this.skipReason = 'Skipping missing credentials test because AWS credentials are set'; + this.skip(); + } + }); + + it('AWS4 signs requests with missing aws env vars', async () => { + await testSigning(undefined); + }); + }); + describe('AWS4 signs requests with AWS permanent env vars', function () { before(function () { if (process.env.AWS_SESSION_TOKEN) { @@ -94,12 +111,12 @@ describe('AwsSigV4', function () { }); it('AWS4 signs requests with AWS session env vars', async () => { - const awsCredentials = { + const awsSesssionCredentials = { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, sessionToken: process.env.AWS_SESSION_TOKEN }; - await testSigning(awsCredentials); + await testSigning(awsSesssionCredentials); }); }); }); diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts new file mode 100644 index 00000000000..f837f6489ab --- /dev/null +++ b/test/unit/aws4.test.ts @@ -0,0 +1,93 @@ +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 missing credentials', () => { + const signed = aws4Sign(request, undefined); + + 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=undefined/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=8854aaaeec4bf1f820435b60e216b610e92fa53cbfca71b269f2c334e02c1c45' + ); + + // 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, undefined); + // 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 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']); + }); +}); From a44f3b491912f57ea73b0ce86a05083ba63ded7e Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Mon, 15 Dec 2025 14:23:21 -0800 Subject: [PATCH 4/7] added test for undefined credentials --- test/integration/auth/aws4.test.ts | 56 +++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/test/integration/auth/aws4.test.ts b/test/integration/auth/aws4.test.ts index f85e4dad2b6..2413ef96b66 100644 --- a/test/integration/auth/aws4.test.ts +++ b/test/integration/auth/aws4.test.ts @@ -9,13 +9,6 @@ import { aws4Sign } from '../../../src/aws4'; // To run this test, simply run `./etc/aws-test.sh`. describe('AwsSigV4', function () { - beforeEach(function () { - if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { - this.skipReason = 'AWS credentials are not present in the environment'; - this.skip(); - } - }); - const testSigning = async credentials => { const host = 'sts.amazonaws.com'; const body = 'Action=GetCallerIdentity&Version=2011-06-15'; @@ -50,7 +43,7 @@ describe('AwsSigV4', function () { for (const [key, value] of Object.entries(headers)) { fetchHeaders.append(key, value.toString()); } - if (credentials.sessionToken) { + if (credentials && credentials.sessionToken) { fetchHeaders.append('X-Amz-Security-Token', credentials.sessionToken); } fetchHeaders.append('Authorization', authorization); @@ -60,12 +53,20 @@ describe('AwsSigV4', function () { headers: fetchHeaders, body }); - expect(response.status).to.equal(200); - expect(response.statusText).to.equal('OK'); const text = await response.text(); - expect(text).to.match( - // - ); + + const expectSuccess = credentials !== undefined; + if (expectSuccess) { + expect(response.status).to.equal(200); + expect(response.statusText).to.equal('OK'); + expect(text).to.match( + // + ); + } else { + expect(response.status).to.equal(403); + expect(response.statusText).to.equal('Forbidden'); + expect(text).to.match(/InvalidClientTokenId<\/Code>/); + } }; describe('AWS4 signs requests with missing AWS env vars', function () { @@ -75,6 +76,11 @@ describe('AwsSigV4', function () { process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SESSION_TOKEN ) { + console.log('Skipping missing credentials test because AWS credentials are set: ', { + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET', + AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' + }); this.skipReason = 'Skipping missing credentials test because AWS credentials are set'; this.skip(); } @@ -88,9 +94,21 @@ describe('AwsSigV4', function () { describe('AWS4 signs requests with AWS permanent env vars', function () { before(function () { if (process.env.AWS_SESSION_TOKEN) { + console.log('Skipping permanent credentials test because AWS_SESSION_TOKEN is set', { + AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' + }); this.skipReason = 'Skipping permanent credentials test because session token is set'; this.skip(); } + + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + console.log('Skipping permanent credentials test because AWS credentials are not set', { + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET' + }); + this.skipReason = 'Skipping permanent credentials test because AWS credentials are not set'; + this.skip(); + } }); it('AWS4 signs requests with AWS permanent env vars', async () => { @@ -105,9 +123,21 @@ describe('AwsSigV4', function () { describe('AWS4 signs requests with AWS session env vars', function () { before(function () { if (!process.env.AWS_SESSION_TOKEN) { + console.log('Skipping session credentials test because AWS_SESSION_TOKEN is not set', { + AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' + }); this.skipReason = 'Skipping session credentials test because session token is not set'; this.skip(); } + + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + console.log('Skipping session credentials test because AWS credentials are not set', { + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET' + }); + this.skipReason = 'Skipping session credentials test because AWS credentials are not set'; + this.skip(); + } }); it('AWS4 signs requests with AWS session env vars', async () => { From 221044d723c24b67f430b9d9f9d171233d61291e Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Wed, 17 Dec 2025 09:48:14 -0800 Subject: [PATCH 5/7] pr feedback: - removed extraneous new types - removed unnecessary AWS4 interface - getHmacArray renamed - removed unnecessary env-reading code - added a bunch of comments about the sigv4 algorithm - removed tests that did not pass in any credentials, we never do this --- src/aws4.ts | 159 ++++++++++++++++------------- test/integration/auth/aws4.test.ts | 22 ---- test/unit/aws4.test.ts | 20 ---- 3 files changed, 89 insertions(+), 112 deletions(-) diff --git a/src/aws4.ts b/src/aws4.ts index 913bbe73a40..d42e2810e00 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -1,5 +1,7 @@ import * as crypto from 'node:crypto'; +import { type AWSCredentials } from './deps'; + export type Options = { path: '/'; body: string; @@ -16,17 +18,6 @@ export type Options = { date?: Date; }; -export type AwsSessionCredentials = { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string; -}; - -export type AwsLongtermCredentials = { - accessKeyId: string; - secretAccessKey: string; -}; - export type SignedHeaders = { headers: { Authorization: string; @@ -34,73 +25,89 @@ export type SignedHeaders = { }; }; -export 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: Options, - credentials: AwsSessionCredentials | AwsLongtermCredentials | undefined - ): SignedHeaders; -} - const getHash = (str: string): string => { return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); }; -const getHmacArray = (key: string | Uint8Array, str: string): Uint8Array => { +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 getEnvCredentials = () => { - const env = process.env; - return { - accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, - sessionToken: env.AWS_SESSION_TOKEN - }; -}; - const convertHeaderValue = (value: string | number) => { return value.toString().trim().replace(/\s+/g, ' '); }; -export function aws4Sign( - this: void, - options: Options, - credentials: AwsSessionCredentials | AwsLongtermCredentials | undefined -): SignedHeaders { - const method = options.method; - const canonicalUri = options.path; - const canonicalQuerystring = ''; - const creds = credentials || getEnvCredentials(); +/** + * 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 + * @param options + * @param credentials + * @returns + */ +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 = ''; - const headers: string[] = [ - `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 ('sessionToken' in creds && creds.sessionToken) { - headers.push(`x-amz-security-token:${convertHeaderValue(creds.sessionToken)}`); + // 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)); } - const canonicalHeaders = headers.sort().join('\n'); - const canonicalHeaderNames = headers.map(header => header.split(':', 2)[0].toLowerCase()); + // 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(';'); - const hashedPayload = getHash(options.body || ''); + // 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, @@ -110,28 +117,40 @@ export function aws4Sign( hashedPayload ].join('\n'); - const canonicalRequestHash = getHash(canonicalRequest); - const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; - - const stringToSign = [ - 'AWS4-HMAC-SHA256', - requestDateTime, - credentialScope, - canonicalRequestHash - ].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); - const dateKey = getHmacArray('AWS4' + creds.secretAccessKey, requestDate); - const dateRegionKey = getHmacArray(dateKey, options.region); - const dateRegionServiceKey = getHmacArray(dateRegionKey, options.service); - const signingKey = getHmacArray(dateRegionServiceKey, 'aws4_request'); + // 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=' + creds.accessKeyId + '/' + credentialScope, + 'AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credentialScope, 'SignedHeaders=' + signedHeaders, 'Signature=' + signature ].join(', '); + // Return the calculated headers return { headers: { Authorization: authorizationHeader, diff --git a/test/integration/auth/aws4.test.ts b/test/integration/auth/aws4.test.ts index 2413ef96b66..63229bf0d4b 100644 --- a/test/integration/auth/aws4.test.ts +++ b/test/integration/auth/aws4.test.ts @@ -69,28 +69,6 @@ describe('AwsSigV4', function () { } }; - describe('AWS4 signs requests with missing AWS env vars', function () { - before(function () { - if ( - process.env.AWS_ACCESS_KEY_ID || - process.env.AWS_SECRET_ACCESS_KEY || - process.env.AWS_SESSION_TOKEN - ) { - console.log('Skipping missing credentials test because AWS credentials are set: ', { - AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', - AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET', - AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' - }); - this.skipReason = 'Skipping missing credentials test because AWS credentials are set'; - this.skip(); - } - }); - - it('AWS4 signs requests with missing aws env vars', async () => { - await testSigning(undefined); - }); - }); - describe('AWS4 signs requests with AWS permanent env vars', function () { before(function () { if (process.env.AWS_SESSION_TOKEN) { diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index f837f6489ab..4c3d7fb2e31 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -31,26 +31,6 @@ describe('Verify AWS4 signature generation', () => { date }; - it('should generate correct credentials for missing credentials', () => { - const signed = aws4Sign(request, undefined); - - 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=undefined/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=8854aaaeec4bf1f820435b60e216b610e92fa53cbfca71b269f2c334e02c1c45' - ); - - // 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, undefined); - // 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 permanent credentials', () => { const signed = aws4Sign(request, awsCredentials); From 72ab61d029847de712ebdabba3c565b5cb46a755 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Wed, 17 Dec 2025 12:39:28 -0800 Subject: [PATCH 6/7] removed extraneous integ test and moved its logic into an existing aws test --- etc/aws-test.sh | 24 ---- src/deps.ts | 2 +- test/integration/auth/aws4.test.ts | 130 ---------------------- test/integration/auth/mongodb_aws.test.ts | 100 +++++++++++++++++ 4 files changed, 101 insertions(+), 155 deletions(-) delete mode 100755 etc/aws-test.sh delete mode 100644 test/integration/auth/aws4.test.ts diff --git a/etc/aws-test.sh b/etc/aws-test.sh deleted file mode 100755 index 72840c099eb..00000000000 --- a/etc/aws-test.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -cd $DRIVERS_TOOLS/.evergreen/auth_aws - -. ./activate-authawsvenv.sh - -# Test with permanent credentials -. aws_setup.sh env-creds -unset MONGODB_URI -echo "AWS_SESSION_TOKEN is set to '${AWS_SESSION_TOKEN-NOT SET}'" -npm run check:test -- --grep "AwsSigV4" - -# Test with session credentials -. aws_setup.sh session-creds -unset MONGODB_URI -echo "AWS_SESSION_TOKEN is set to '${AWS_SESSION_TOKEN-NOT SET}'" -npm run check:test -- --grep "AwsSigV4" - -# Test with missing credentials -unset MONGODB_URI -unset AWS_ACCESS_KEY_ID -unset AWS_SECRET_ACCESS_KEY -unset AWS_SESSION_TOKEN -npm run check:test -- --grep "AwsSigV4" diff --git a/src/deps.ts b/src/deps.ts index f4c0b0f9cad..300d1daed1b 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 } } diff --git a/test/integration/auth/aws4.test.ts b/test/integration/auth/aws4.test.ts deleted file mode 100644 index 63229bf0d4b..00000000000 --- a/test/integration/auth/aws4.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as process from 'node:process'; - -import { expect } from 'chai'; - -import { aws4Sign } from '../../../src/aws4'; - -// 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. -// To run this test, simply run `./etc/aws-test.sh`. - -describe('AwsSigV4', function () { - const testSigning = async credentials => { - 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 - }, - credentials - ); - - 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(); - - const expectSuccess = credentials !== undefined; - if (expectSuccess) { - expect(response.status).to.equal(200); - expect(response.statusText).to.equal('OK'); - expect(text).to.match( - // - ); - } else { - expect(response.status).to.equal(403); - expect(response.statusText).to.equal('Forbidden'); - expect(text).to.match(/InvalidClientTokenId<\/Code>/); - } - }; - - describe('AWS4 signs requests with AWS permanent env vars', function () { - before(function () { - if (process.env.AWS_SESSION_TOKEN) { - console.log('Skipping permanent credentials test because AWS_SESSION_TOKEN is set', { - AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' - }); - this.skipReason = 'Skipping permanent credentials test because session token is set'; - this.skip(); - } - - if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { - console.log('Skipping permanent credentials test because AWS credentials are not set', { - AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', - AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET' - }); - this.skipReason = 'Skipping permanent credentials test because AWS credentials are not set'; - this.skip(); - } - }); - - it('AWS4 signs requests with AWS permanent env vars', async () => { - const awsCredentials = { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY - }; - await testSigning(awsCredentials); - }); - }); - - describe('AWS4 signs requests with AWS session env vars', function () { - before(function () { - if (!process.env.AWS_SESSION_TOKEN) { - console.log('Skipping session credentials test because AWS_SESSION_TOKEN is not set', { - AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN ? 'SET' : 'NOT SET' - }); - this.skipReason = 'Skipping session credentials test because session token is not set'; - this.skip(); - } - - if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { - console.log('Skipping session credentials test because AWS credentials are not set', { - AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID ? 'SET' : 'NOT SET', - AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ? 'SET' : 'NOT SET' - }); - this.skipReason = 'Skipping session credentials test because AWS credentials are not set'; - this.skip(); - } - }); - - it('AWS4 signs requests with AWS session env vars', async () => { - const awsSesssionCredentials = { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - sessionToken: process.env.AWS_SESSION_TOKEN - }; - await testSigning(awsSesssionCredentials); - }); - }); -}); diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index ee35a16ea10..b3fe374c4fa 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'; @@ -225,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', () => { From 037bcf802e79db86176fdd51e18f6b6be7435af4 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Wed, 17 Dec 2025 13:23:13 -0800 Subject: [PATCH 7/7] minor fixes --- src/aws4.ts | 3 --- src/cmap/auth/mongodb_aws.ts | 11 ++++------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/aws4.ts b/src/aws4.ts index d42e2810e00..c34a328b144 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -42,9 +42,6 @@ const convertHeaderValue = (value: string | number) => { /** * 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 - * @param options - * @param credentials - * @returns */ export function aws4Sign(options: Options, credentials: AWSCredentials): SignedHeaders { /** diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 0cb0b5201fe..458c42f227e 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -63,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);