From 7cc115669255af0128cd3b378d42f580d807935d Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 11 Dec 2025 14:11:11 -0800 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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); From fe3c90b1b1700f0b97772a6397949331002fcf29 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 19 Dec 2025 08:56:10 -0800 Subject: [PATCH 08/19] use webcrypto for new code --- src/aws4.ts | 54 ++++++++++++++++------- src/cmap/auth/mongodb_aws.ts | 2 +- test/integration/auth/mongodb_aws.test.ts | 2 +- test/unit/aws4.test.ts | 8 ++-- 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/aws4.ts b/src/aws4.ts index c34a328b144..5897a6fbd7d 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -1,5 +1,3 @@ -import * as crypto from 'node:crypto'; - import { type AWSCredentials } from './deps'; export type Options = { @@ -25,14 +23,35 @@ export type SignedHeaders = { }; }; -const getHash = (str: string): string => { - return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); +const crypto = globalThis.crypto; + +const getHash = async (str: string): Promise => { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; }; -const getHmacBuffer = (key: string | Uint8Array, str: string): Uint8Array => { - return crypto.createHmac('sha256', key).update(str, 'utf8').digest(); +const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { + const encoder = new TextEncoder(); + const keyData = typeof key === 'string' ? encoder.encode(key) : key; + const importedKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: { name: 'SHA-256' } }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', importedKey, encoder.encode(str)); + const digest = new Uint8Array(signature); + return digest; }; -const getHmacString = (key: Uint8Array, str: string): string => { - return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); +const getHmacString = async (key: Uint8Array, str: string): Promise => { + const hmacBuffer = await getHmacBuffer(key, str); + const hashArray = Array.from(hmacBuffer); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; }; const convertHeaderValue = (value: string | number) => { @@ -43,7 +62,10 @@ 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 */ -export function aws4Sign(options: Options, credentials: AWSCredentials): SignedHeaders { +export async function aws4Sign( + options: Options, + credentials: AWSCredentials +): Promise { /** * From the spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html * @@ -102,7 +124,7 @@ export function aws4Sign(options: Options, credentials: AWSCredentials): SignedH 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); + const hashedPayload = await getHash(options.body); // CanonicalRequest – A string that includes the above elements, separated by newline characters. const canonicalRequest = [ @@ -116,7 +138,7 @@ export function aws4Sign(options: Options, credentials: AWSCredentials): SignedH // 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 hashedCanonicalRequest = await 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. @@ -131,13 +153,13 @@ export function aws4Sign(options: Options, credentials: AWSCredentials): SignedH // 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'); + const dateKey = await getHmacBuffer('AWS4' + credentials.secretAccessKey, requestDate); + const dateRegionKey = await getHmacBuffer(dateKey, options.region); + const dateRegionServiceKey = await getHmacBuffer(dateRegionKey, options.service); + const signingKey = await getHmacBuffer(dateRegionServiceKey, 'aws4_request'); // 5. Calculate the signature - const signature = getHmacString(signingKey, stringToSign); + const signature = await getHmacString(signingKey, stringToSign); // 6. Add the signature to the request // Calculate the Authorization header diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 458c42f227e..ee6fcac4ab4 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -106,7 +106,7 @@ export class MongoDBAWS extends AuthProvider { } const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const signed = aws4Sign( + const signed = await aws4Sign( { method: 'POST', host, diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index b3fe374c4fa..c45d86a0f57 100644 --- a/test/integration/auth/mongodb_aws.test.ts +++ b/test/integration/auth/mongodb_aws.test.ts @@ -260,7 +260,7 @@ describe('MONGODB-AWS', function () { 'X-MongoDB-Server-Nonce': 'fakenonce', 'X-MongoDB-GS2-CB-Flag': 'n' }; - const signed = aws4Sign( + const signed = await aws4Sign( { method: 'POST', host, diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index 4c3d7fb2e31..8ac253f1ad2 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -31,8 +31,8 @@ describe('Verify AWS4 signature generation', () => { date }; - it('should generate correct credentials for permanent credentials', () => { - const signed = aws4Sign(request, awsCredentials); + it('should generate correct credentials for permanent credentials', async () => { + const signed = await aws4Sign(request, awsCredentials); expect(signed.headers['X-Amz-Date']).to.exist; expect(signed.headers['X-Amz-Date']).to.equal('20251215T123456Z'); @@ -51,8 +51,8 @@ describe('Verify AWS4 signature generation', () => { // expect(oldSigned.headers['Authorization']).to.equal(signed.headers['Authorization']); }); - it('should generate correct credentials for session credentials', () => { - const signed = aws4Sign(request, awsSessionCredentials); + it('should generate correct credentials for session credentials', async () => { + const signed = await aws4Sign(request, awsSessionCredentials); expect(signed.headers['X-Amz-Date']).to.exist; expect(signed.headers['X-Amz-Date']).to.equal('20251215T123456Z'); From 021f9defcb392a2a444e58029dfbf09bf52556dc Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 19 Dec 2025 10:52:52 -0800 Subject: [PATCH 09/19] use ByteUtils.toHex --- src/aws4.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/aws4.ts b/src/aws4.ts index 5897a6fbd7d..57e93b13273 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -1,3 +1,4 @@ +import { BSON } from './bson'; import { type AWSCredentials } from './deps'; export type Options = { @@ -23,14 +24,11 @@ export type SignedHeaders = { }; }; -const crypto = globalThis.crypto; - const getHash = async (str: string): Promise => { const encoder = new TextEncoder(); const data = encoder.encode(str); const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + const hashHex = BSON.onDemand.ByteUtils.toHex(new Uint8Array(hashBuffer)); return hashHex; }; const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { @@ -49,8 +47,7 @@ const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { const hmacBuffer = await getHmacBuffer(key, str); - const hashArray = Array.from(hmacBuffer); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + const hashHex = BSON.onDemand.ByteUtils.toHex(hmacBuffer); return hashHex; }; From d7966a3c0476272aa3338ca2bfb1c5791c0f4d33 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 19 Dec 2025 11:08:07 -0800 Subject: [PATCH 10/19] use ByteUtils.encodeUTF8Into --- src/aws4.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/aws4.ts b/src/aws4.ts index 57e93b13273..e348299700f 100644 --- a/src/aws4.ts +++ b/src/aws4.ts @@ -25,15 +25,21 @@ export type SignedHeaders = { }; const getHash = async (str: string): Promise => { - const encoder = new TextEncoder(); - const data = encoder.encode(str); + const data = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str)); + BSON.onDemand.ByteUtils.encodeUTF8Into(data, str, 0); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashHex = BSON.onDemand.ByteUtils.toHex(new Uint8Array(hashBuffer)); return hashHex; }; const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { - const encoder = new TextEncoder(); - const keyData = typeof key === 'string' ? encoder.encode(key) : key; + let keyData: Uint8Array; + if (typeof key === 'string') { + keyData = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(key)); + BSON.onDemand.ByteUtils.encodeUTF8Into(keyData, key, 0); + } else { + keyData = key; + } + const importedKey = await crypto.subtle.importKey( 'raw', keyData, @@ -41,7 +47,9 @@ const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise Date: Mon, 5 Jan 2026 10:55:33 -0800 Subject: [PATCH 11/19] pr feedback --- src/{ => cmap/auth}/aws4.ts | 40 ++++++++++++++++------- src/cmap/auth/mongodb_aws.ts | 5 +-- test/integration/auth/mongodb_aws.test.ts | 5 +-- test/unit/aws4.test.ts | 2 +- 4 files changed, 36 insertions(+), 16 deletions(-) rename src/{ => cmap/auth}/aws4.ts (87%) diff --git a/src/aws4.ts b/src/cmap/auth/aws4.ts similarity index 87% rename from src/aws4.ts rename to src/cmap/auth/aws4.ts index e348299700f..44d9d689f26 100644 --- a/src/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -1,5 +1,5 @@ -import { BSON } from './bson'; -import { type AWSCredentials } from './deps'; +import { BSON } from '../../bson'; +import { type AWSCredentials } from '../../deps'; export type Options = { path: '/'; @@ -14,7 +14,7 @@ export type Options = { }; service: string; region: string; - date?: Date; + date: Date; }; export type SignedHeaders = { @@ -24,6 +24,12 @@ export type SignedHeaders = { }; }; +/** + * Calculates the SHA-256 hash of a string. + * + * @param str - String to hash. + * @returns Hexadecimal representation of the hash. + */ const getHash = async (str: string): Promise => { const data = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str)); BSON.onDemand.ByteUtils.encodeUTF8Into(data, str, 0); @@ -31,6 +37,13 @@ const getHash = async (str: string): Promise => { const hashHex = BSON.onDemand.ByteUtils.toHex(new Uint8Array(hashBuffer)); return hashHex; }; + +/** + * Calculates the HMAC-SHA256 of a string using the provided key. + * @param key - Key to use for HMAC calculation. Can be a string or Uint8Array. + * @param str - String to calculate HMAC for. + * @returns Uint8Array containing the HMAC-SHA256 digest. + */ const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { let keyData: Uint8Array; if (typeof key === 'string') { @@ -53,12 +66,16 @@ const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { - const hmacBuffer = await getHmacBuffer(key, str); - const hashHex = BSON.onDemand.ByteUtils.toHex(hmacBuffer); - return hashHex; -}; +/** + * Converts header values according to AWS requirements, + * From https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request + * For values, you must: + - trim any leading or trailing spaces. + - convert sequential spaces to a single space. + * @param value - Header value to convert. + * @returns - Converted header value. + */ const convertHeaderValue = (value: string | number) => { return value.toString().trim().replace(/\s+/g, ' '); }; @@ -91,8 +108,8 @@ export async function aws4Sign( // 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(); + // Date – The date and time used to sign the request. + const date = options.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). @@ -164,7 +181,8 @@ export async function aws4Sign( const signingKey = await getHmacBuffer(dateRegionServiceKey, 'aws4_request'); // 5. Calculate the signature - const signature = await getHmacString(signingKey, stringToSign); + const signatureBuffer = await getHmacBuffer(signingKey, stringToSign); + const signature = BSON.onDemand.ByteUtils.toHex(signatureBuffer); // 6. Add the signature to the request // Calculate the Authorization header diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index ee6fcac4ab4..fbb33f0b160 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -1,4 +1,3 @@ -import { aws4Sign } from '../../aws4'; import type { Binary, BSONSerializeOptions } from '../../bson'; import * as BSON from '../../bson'; import { @@ -13,6 +12,7 @@ import { AWSSDKCredentialProvider, type AWSTempCredentials } from './aws_temporary_credentials'; +import { aws4Sign } from './aws4'; import { MongoCredentials } from './mongo_credentials'; import { AuthMechanism } from './providers'; @@ -119,7 +119,8 @@ export class MongoDBAWS extends AuthProvider { 'X-MongoDB-GS2-CB-Flag': 'n' }, path: '/', - body + body, + date: new Date() }, awsCredentials ); diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index c45d86a0f57..2eae2889e8d 100644 --- a/test/integration/auth/mongodb_aws.test.ts +++ b/test/integration/auth/mongodb_aws.test.ts @@ -17,9 +17,9 @@ 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 { aws4Sign } from '../../../src/cmap/auth/aws4'; import { MongoDBAWS } from '../../../src/cmap/auth/mongodb_aws'; import { Connection } from '../../../src/cmap/connection'; import { setDifference } from '../../../src/utils'; @@ -268,7 +268,8 @@ describe('MONGODB-AWS', function () { region: 'us-east-1', service: 'sts', headers: headers, - body + body, + date: new Date() }, creds ); diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index 8ac253f1ad2..59456066f82 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { aws4Sign, type Options } from '../../src/aws4'; +import { aws4Sign, type Options } from '../../src/cmap/auth/aws4'; describe('Verify AWS4 signature generation', () => { const date = new Date('2025-12-15T12:34:56Z'); From 9178f6623b94f23058d80fd13c1c0929d4f9531e Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 6 Jan 2026 10:10:29 -0800 Subject: [PATCH 12/19] Update src/cmap/auth/aws4.ts rename Options to AwsSigv4Options Co-authored-by: Bailey Pearson --- src/cmap/auth/aws4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmap/auth/aws4.ts b/src/cmap/auth/aws4.ts index 44d9d689f26..943aca4d2dd 100644 --- a/src/cmap/auth/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -1,7 +1,7 @@ import { BSON } from '../../bson'; import { type AWSCredentials } from '../../deps'; -export type Options = { +export type AwsSigv4Options = { path: '/'; body: string; host: string; From 5a8380f833a161f001cc4b2ab380dd6d642bc71b Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 6 Jan 2026 10:23:19 -0800 Subject: [PATCH 13/19] pr feedback --- src/cmap/auth/aws4.ts | 26 +++++++++++--------------- src/cmap/auth/mongodb_aws.ts | 6 +++--- src/deps.ts | 2 +- test/unit/aws4.test.ts | 24 ++++++++++++------------ 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/cmap/auth/aws4.ts b/src/cmap/auth/aws4.ts index 943aca4d2dd..b5b90766308 100644 --- a/src/cmap/auth/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -18,10 +18,8 @@ export type AwsSigv4Options = { }; export type SignedHeaders = { - headers: { - Authorization: string; - 'X-Amz-Date': string; - }; + Authorization: string; + 'X-Amz-Date': string; }; /** @@ -44,7 +42,7 @@ const getHash = async (str: string): Promise => { * @param str - String to calculate HMAC for. * @returns Uint8Array containing the HMAC-SHA256 digest. */ -const getHmacBuffer = async (key: string | Uint8Array, str: string): Promise => { +const getHmacSha256 = async (key: string | Uint8Array, str: string): Promise => { let keyData: Uint8Array; if (typeof key === 'string') { keyData = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(key)); @@ -85,7 +83,7 @@ const convertHeaderValue = (value: string | number) => { * The signing logic is described here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html */ export async function aws4Sign( - options: Options, + options: AwsSigv4Options, credentials: AWSCredentials ): Promise { /** @@ -175,13 +173,13 @@ export async function aws4Sign( // 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 = await getHmacBuffer('AWS4' + credentials.secretAccessKey, requestDate); - const dateRegionKey = await getHmacBuffer(dateKey, options.region); - const dateRegionServiceKey = await getHmacBuffer(dateRegionKey, options.service); - const signingKey = await getHmacBuffer(dateRegionServiceKey, 'aws4_request'); + const dateKey = await getHmacSha256('AWS4' + credentials.secretAccessKey, requestDate); + const dateRegionKey = await getHmacSha256(dateKey, options.region); + const dateRegionServiceKey = await getHmacSha256(dateRegionKey, options.service); + const signingKey = await getHmacSha256(dateRegionServiceKey, 'aws4_request'); // 5. Calculate the signature - const signatureBuffer = await getHmacBuffer(signingKey, stringToSign); + const signatureBuffer = await getHmacSha256(signingKey, stringToSign); const signature = BSON.onDemand.ByteUtils.toHex(signatureBuffer); // 6. Add the signature to the request @@ -194,9 +192,7 @@ export async function aws4Sign( // Return the calculated headers return { - headers: { - Authorization: authorizationHeader, - 'X-Amz-Date': requestDateTime - } + Authorization: authorizationHeader, + 'X-Amz-Date': requestDateTime }; } diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index fbb33f0b160..b9a2cdef0a7 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -106,7 +106,7 @@ export class MongoDBAWS extends AuthProvider { } const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const signed = await aws4Sign( + const headers = await aws4Sign( { method: 'POST', host, @@ -126,8 +126,8 @@ export class MongoDBAWS extends AuthProvider { ); const payload: AWSSaslContinuePayload = { - a: signed.headers.Authorization, - d: signed.headers['X-Amz-Date'] + a: headers.Authorization, + d: headers['X-Amz-Date'] }; if (sessionToken) { diff --git a/src/deps.ts b/src/deps.ts index 300d1daed1b..f4c0b0f9cad 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -89,7 +89,7 @@ export interface AWSCredentials { expiration?: Date; } -export type CredentialProvider = { +type CredentialProvider = { fromNodeProviderChain( this: void, options: { clientConfig: { region: string } } diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index 59456066f82..080f9fa4d04 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { aws4Sign, type Options } from '../../src/cmap/auth/aws4'; +import { aws4Sign, type AwsSigv4Options } from '../../src/cmap/auth/aws4'; describe('Verify AWS4 signature generation', () => { const date = new Date('2025-12-15T12:34:56Z'); @@ -15,7 +15,7 @@ describe('Verify AWS4 signature generation', () => { }; const host = 'sts.amazonaws.com'; const body = 'Action=GetCallerIdentity&Version=2011-06-15'; - const request: Options = { + const request: AwsSigv4Options = { method: 'POST', host, path: '/', @@ -32,12 +32,12 @@ describe('Verify AWS4 signature generation', () => { }; it('should generate correct credentials for permanent credentials', async () => { - const signed = await aws4Sign(request, awsCredentials); + const headers = await 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( + expect(headers['X-Amz-Date']).to.exist; + expect(headers['X-Amz-Date']).to.equal('20251215T123456Z'); + expect(headers['Authorization']).to.exist; + expect(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' ); @@ -52,12 +52,12 @@ describe('Verify AWS4 signature generation', () => { }); it('should generate correct credentials for session credentials', async () => { - const signed = await aws4Sign(request, awsSessionCredentials); + const headers = await 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( + expect(headers['X-Amz-Date']).to.exist; + expect(headers['X-Amz-Date']).to.equal('20251215T123456Z'); + expect(headers['Authorization']).to.exist; + expect(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' ); From a3c06e4cbec5362e8d968036e10c6a5e3b7d329b Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 6 Jan 2026 12:30:48 -0800 Subject: [PATCH 14/19] minor fix --- test/integration/auth/mongodb_aws.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/auth/mongodb_aws.test.ts b/test/integration/auth/mongodb_aws.test.ts index 2eae2889e8d..8e6e74cc079 100644 --- a/test/integration/auth/mongodb_aws.test.ts +++ b/test/integration/auth/mongodb_aws.test.ts @@ -260,7 +260,7 @@ describe('MONGODB-AWS', function () { 'X-MongoDB-Server-Nonce': 'fakenonce', 'X-MongoDB-GS2-CB-Flag': 'n' }; - const signed = await aws4Sign( + const signedHeaders = await aws4Sign( { method: 'POST', host, @@ -274,8 +274,8 @@ describe('MONGODB-AWS', function () { creds ); - const authorization = signed.headers.Authorization; - const xAmzDate = signed.headers['X-Amz-Date']; + const authorization = signedHeaders.Authorization; + const xAmzDate = signedHeaders['X-Amz-Date']; const fetchHeaders = new Headers(); for (const [key, value] of Object.entries(headers)) { From 2e69f64cff52bd39cf8e73294b854bb983685d89 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Wed, 7 Jan 2026 10:10:44 -0800 Subject: [PATCH 15/19] removing unnecessary bit of code --- src/cmap/auth/aws4.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cmap/auth/aws4.ts b/src/cmap/auth/aws4.ts index b5b90766308..a113ed89e1a 100644 --- a/src/cmap/auth/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -130,10 +130,7 @@ export async function aws4Sign( '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}`) From 59f3e2615e2107f7304cd9a4c89e1cbe36468453 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Wed, 7 Jan 2026 10:24:01 -0800 Subject: [PATCH 16/19] update aws4 test --- test/unit/aws4.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index 080f9fa4d04..fe6f7aed533 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -58,7 +58,7 @@ describe('Verify AWS4 signature generation', () => { expect(headers['X-Amz-Date']).to.equal('20251215T123456Z'); expect(headers['Authorization']).to.exist; expect(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' + '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=7bfe0c6c8c0aa9f853eb10c5822ab42446ad87789e5b6e47a6fbd7a9bffc834a' ); // Uncomment the following lines if you want to compare with the old aws4 library. From 178b90a1adc2ce3f61ffb50f686fea0b5e26a3c1 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 8 Jan 2026 07:45:55 -0800 Subject: [PATCH 17/19] pr feedback --- src/cmap/auth/aws4.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/cmap/auth/aws4.ts b/src/cmap/auth/aws4.ts index a113ed89e1a..e234ec3155e 100644 --- a/src/cmap/auth/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -28,9 +28,8 @@ export type SignedHeaders = { * @param str - String to hash. * @returns Hexadecimal representation of the hash. */ -const getHash = async (str: string): Promise => { - const data = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str)); - BSON.onDemand.ByteUtils.encodeUTF8Into(data, str, 0); +const getHexSha256 = async (str: string): Promise => { + const data = stringToBuffer(str); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashHex = BSON.onDemand.ByteUtils.toHex(new Uint8Array(hashBuffer)); return hashHex; @@ -45,8 +44,7 @@ const getHash = async (str: string): Promise => { const getHmacSha256 = async (key: string | Uint8Array, str: string): Promise => { let keyData: Uint8Array; if (typeof key === 'string') { - keyData = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(key)); - BSON.onDemand.ByteUtils.encodeUTF8Into(keyData, key, 0); + keyData = stringToBuffer(key); } else { keyData = key; } @@ -58,8 +56,7 @@ const getHmacSha256 = async (key: string | Uint8Array, str: string): Promise { return value.toString().trim().replace(/\s+/g, ' '); }; +/** + * Returns a Uint8Array representation of a string, encoded in UTF-8. + * @param str - String to convert. + * @returns Uint8Array containing the UTF-8 encoded string. + */ +function stringToBuffer(str: string): Uint8Array { + const data = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str)); + BSON.onDemand.ByteUtils.encodeUTF8Into(data, str, 0); + return data; +} + /** * 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 @@ -141,7 +149,7 @@ export async function aws4Sign( 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 = await getHash(options.body); + const hashedPayload = await getHexSha256(options.body); // CanonicalRequest – A string that includes the above elements, separated by newline characters. const canonicalRequest = [ @@ -155,7 +163,7 @@ export async function aws4Sign( // 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 = await getHash(canonicalRequest); + const hashedCanonicalRequest = await getHexSha256(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. From 4e88199e54b23ace28f9d036a570685f98707cef Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 8 Jan 2026 12:38:46 -0800 Subject: [PATCH 18/19] add aws4 as dev dependency and verify our code generates the same signatures --- package-lock.json | 7 +++++++ package.json | 1 + src/cmap/auth/aws4.ts | 4 ++++ test/unit/aws4.test.ts | 42 +++++++++++++++++++++++++----------------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc1d8ae8968..962bd66f7a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.3.0", + "aws4": "^1.13.2", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, @@ -3761,6 +3762,12 @@ "node": "*" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index ba679230952..b5f9bf187f8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@mongodb-js/saslprep": "^1.3.0", + "aws4": "^1.13.2", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, diff --git a/src/cmap/auth/aws4.ts b/src/cmap/auth/aws4.ts index e234ec3155e..912cdbdcaa5 100644 --- a/src/cmap/auth/aws4.ts +++ b/src/cmap/auth/aws4.ts @@ -138,6 +138,10 @@ export async function aws4Sign( '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()) diff --git a/test/unit/aws4.test.ts b/test/unit/aws4.test.ts index fe6f7aed533..11e69bb76df 100644 --- a/test/unit/aws4.test.ts +++ b/test/unit/aws4.test.ts @@ -1,4 +1,6 @@ +import * as aws4sign from 'aws4'; import { expect } from 'chai'; +import * as sinon from 'sinon'; import { aws4Sign, type AwsSigv4Options } from '../../src/cmap/auth/aws4'; @@ -31,9 +33,18 @@ describe('Verify AWS4 signature generation', () => { date }; + beforeEach(() => { + sinon.stub(aws4sign.RequestSigner.prototype, 'getDateTime').returns('20251215T123456Z'); + }); + + afterEach(() => { + sinon.restore(); + }); + it('should generate correct credentials for permanent credentials', async () => { const headers = await aws4Sign(request, awsCredentials); + // Verify generated headers expect(headers['X-Amz-Date']).to.exist; expect(headers['X-Amz-Date']).to.equal('20251215T123456Z'); expect(headers['Authorization']).to.exist; @@ -41,33 +52,30 @@ describe('Verify AWS4 signature generation', () => { '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']); + // Verify against aws4 library + const oldSigned = aws4sign.sign(request, awsCredentials); + expect(oldSigned.headers['X-Amz-Date']).to.exist; + expect(oldSigned.headers['X-Amz-Date']).to.equal(headers['X-Amz-Date']); + expect(oldSigned.headers['Authorization']).to.exist; + expect(oldSigned.headers['Authorization']).to.equal(headers['Authorization']); }); it('should generate correct credentials for session credentials', async () => { const headers = await aws4Sign(request, awsSessionCredentials); + // Verify generated headers expect(headers['X-Amz-Date']).to.exist; expect(headers['X-Amz-Date']).to.equal('20251215T123456Z'); expect(headers['Authorization']).to.exist; expect(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=7bfe0c6c8c0aa9f853eb10c5822ab42446ad87789e5b6e47a6fbd7a9bffc834a' + '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']); + // Verify against aws4 library + const oldSigned = aws4sign.sign(request, awsSessionCredentials); + expect(oldSigned.headers['X-Amz-Date']).to.exist; + expect(oldSigned.headers['X-Amz-Date']).to.equal(headers['X-Amz-Date']); + expect(oldSigned.headers['Authorization']).to.exist; + expect(oldSigned.headers['Authorization']).to.equal(headers['Authorization']); }); }); From a4d722afab156bd21dc2799c1070a0e4d19cb07c Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 8 Jan 2026 12:49:32 -0800 Subject: [PATCH 19/19] make aws4 a dev dependency --- package-lock.json | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 962bd66f7a0..887e4c95232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.3.0", - "aws4": "^1.13.2", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, @@ -34,6 +33,7 @@ "@types/whatwg-url": "^13.0.0", "@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/parser": "^8.31.1", + "aws4": "^1.13.2", "chai": "^4.4.1", "chai-subset": "^1.6.0", "chalk": "^4.1.2", @@ -3766,6 +3766,7 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, "license": "MIT" }, "node_modules/balanced-match": { diff --git a/package.json b/package.json index b5f9bf187f8..3f7c7f83666 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ }, "dependencies": { "@mongodb-js/saslprep": "^1.3.0", - "aws4": "^1.13.2", "bson": "^7.0.0", "mongodb-connection-string-url": "^7.0.0" }, @@ -82,6 +81,7 @@ "@types/whatwg-url": "^13.0.0", "@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/parser": "^8.31.1", + "aws4": "^1.13.2", "chai": "^4.4.1", "chai-subset": "^1.6.0", "chalk": "^4.1.2",