Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7cc1156
sigv4 implementation
PavelSafronov Dec 11, 2025
811d453
fix issues and add a test, and a simple way to run it
PavelSafronov Dec 12, 2025
a0ba1ec
added unit tests for the signing logic, added comments about how to c…
PavelSafronov Dec 15, 2025
449d677
Merge branch 'main' into NODE-5393
PavelSafronov Dec 15, 2025
a44f3b4
added test for undefined credentials
PavelSafronov Dec 15, 2025
221044d
pr feedback:
PavelSafronov Dec 17, 2025
72ab61d
removed extraneous integ test and moved its logic into an existing aw…
PavelSafronov Dec 17, 2025
037bcf8
minor fixes
PavelSafronov Dec 17, 2025
fe3c90b
use webcrypto for new code
PavelSafronov Dec 19, 2025
021f9de
use ByteUtils.toHex
PavelSafronov Dec 19, 2025
d7966a3
use ByteUtils.encodeUTF8Into
PavelSafronov Dec 19, 2025
3a2a0ee
pr feedback
PavelSafronov Jan 5, 2026
9178f66
Update src/cmap/auth/aws4.ts
PavelSafronov Jan 6, 2026
5a8380f
pr feedback
PavelSafronov Jan 6, 2026
a3c06e4
minor fix
PavelSafronov Jan 6, 2026
26fecf5
Merge branch 'main' into NODE-5393
PavelSafronov Jan 6, 2026
a625dc5
Merge branch 'main' into NODE-5393
PavelSafronov Jan 7, 2026
2e69f64
removing unnecessary bit of code
PavelSafronov Jan 7, 2026
31f49e7
Merge branch 'main' into NODE-5393
PavelSafronov Jan 7, 2026
59f3e26
update aws4 test
PavelSafronov Jan 7, 2026
41a18ab
Merge branch 'main' into NODE-5393
PavelSafronov Jan 7, 2026
178b90a
pr feedback
PavelSafronov Jan 8, 2026
4e88199
add aws4 as dev dependency and verify our code generates the same sig…
PavelSafronov Jan 8, 2026
a4d722a
make aws4 a dev dependency
PavelSafronov Jan 8, 2026
6fffef6
Merge branch 'main' into NODE-5393
PavelSafronov Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .evergreen/run-mongodb-aws-ecs-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions .evergreen/setup-mongodb-aws-auth-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,5 @@ cd $DRIVERS_TOOLS/.evergreen/auth_aws

cd $BEFORE

npm install --no-save aws4

# revert to show test output
set -x
202 changes: 202 additions & 0 deletions src/cmap/auth/aws4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { BSON } from '../../bson';
import { type AWSCredentials } from '../../deps';

export type Options = {
path: '/';
body: string;
host: string;
method: 'POST';
headers: {
'Content-Type': 'application/x-www-form-urlencoded';
'Content-Length': number;
'X-MongoDB-Server-Nonce': string;
'X-MongoDB-GS2-CB-Flag': 'n';
};
service: string;
region: string;
date: Date;
};

export type SignedHeaders = {
headers: {
Authorization: string;
'X-Amz-Date': string;
};
};

/**
* 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<string> => {
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;
};

/**
* 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<Uint8Array> => {
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,
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const strData = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str));
BSON.onDemand.ByteUtils.encodeUTF8Into(strData, str, 0);
const signature = await crypto.subtle.sign('HMAC', importedKey, strData);
const digest = new Uint8Array(signature);
return digest;
};

/**
* 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, ' ');
};

/**
* 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 async function aws4Sign(
options: Options,
credentials: AWSCredentials
): Promise<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.
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).
const requestDate = requestDateTime.substring(0, 8);
// Method – The HTTP request method. For us, this is always 'POST'.
const method = options.method;
// CanonicalUri – The URI-encoded version of the absolute path component URI, starting with the / that follows the domain name and up to the end of the string
// For our requests, this is always '/'
const canonicalUri = options.path;
// CanonicalQueryString – The URI-encoded query string parameters. For our requests, there are no query string parameters, so this is always an empty string.
const canonicalQuerystring = '';

// CanonicalHeaders – A list of request headers with their values. Individual header name and value pairs are separated by the newline character ("\n").
// All of our known/expected headers are included here, there are no extra headers.
const headers = new Headers({
'content-length': convertHeaderValue(options.headers['Content-Length']),
'content-type': convertHeaderValue(options.headers['Content-Type']),
host: convertHeaderValue(options.host),
'x-amz-date': convertHeaderValue(requestDateTime),
'x-mongodb-gs2-cb-flag': convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag']),
'x-mongodb-server-nonce': convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])
});
// If session token is provided, include it in the headers
if ('sessionToken' in credentials && credentials.sessionToken) {
headers.append('x-amz-security-token', convertHeaderValue(credentials.sessionToken));
}
// Canonical headers are lowercased and sorted.
const canonicalHeaders = Array.from(headers.entries())
.map(([key, value]) => `${key.toLowerCase()}:${value}`)
.sort()
.join('\n');
const canonicalHeaderNames = Array.from(headers.keys()).map(header => header.toLowerCase());
// SignedHeaders – An alphabetically sorted, semicolon-separated list of lowercase request header names.
const signedHeaders = canonicalHeaderNames.sort().join(';');

// HashedPayload – A string created using the payload in the body of the HTTP request as input to a hash function. This string uses lowercase hexadecimal characters.
const hashedPayload = await getHash(options.body);

// CanonicalRequest – A string that includes the above elements, separated by newline characters.
const canonicalRequest = [
method,
canonicalUri,
canonicalQuerystring,
canonicalHeaders + '\n',
signedHeaders,
hashedPayload
].join('\n');

// 2. Create a hash of the canonical request
// HashedCanonicalRequest – A string created by using the canonical request as input to a hash function.
const hashedCanonicalRequest = 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.
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 = 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 signatureBuffer = await getHmacBuffer(signingKey, stringToSign);
const signature = BSON.onDemand.ByteUtils.toHex(signatureBuffer);

// 6. Add the signature to the request
// Calculate the Authorization header
const authorizationHeader = [
'AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credentialScope,
'SignedHeaders=' + signedHeaders,
'Signature=' + signature
].join(', ');

// Return the calculated headers
return {
headers: {
Authorization: authorizationHeader,
'X-Amz-Date': requestDateTime
}
};
}
27 changes: 10 additions & 17 deletions src/cmap/auth/mongodb_aws.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Binary, BSONSerializeOptions } from '../../bson';
import * as BSON from '../../bson';
import { aws4 } from '../../deps';
import {
MongoCompatibilityError,
MongoMissingCredentialsError,
Expand All @@ -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';

Expand Down Expand Up @@ -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'
Expand All @@ -68,13 +63,10 @@ export class MongoDBAWS extends AuthProvider {
// Allow the user to specify an AWS session token for authentication with temporary credentials.
const sessionToken = credentials.mechanismProperties.AWS_SESSION_TOKEN;

// If all three defined, include sessionToken, else include username and pass, else no credentials
const awsCredentials =
accessKeyId && secretAccessKey && sessionToken
? { accessKeyId, secretAccessKey, sessionToken }
: accessKeyId && secretAccessKey
? { accessKeyId, secretAccessKey }
: undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this case (awsCredentials = undefined) no longer valid?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is based on Bailey's earlier comment:

We only ever call this with credentials already fetched - could we make this explicitly required?

But maybe we should throw another MongoMissingCredentialsError if we find that credentials.username or credentials.password are empty?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was dead code before. these values come from above:

    authContext.credentials = await makeTempCredentials(
      authContext.credentials,
      this.credentialFetcher
    );

    const { credentials } = authContext;

    const accessKeyId = credentials.username;
    const secretAccessKey = credentials.password;
    // Allow the user to specify an AWS session token for authentication with temporary credentials.
    const sessionToken = credentials.mechanismProperties.AWS_SESSION_TOKEN;

and:

async function makeTempCredentials(
  credentials: MongoCredentials,
  awsCredentialFetcher: AWSSDKCredentialProvider
): Promise<MongoCredentials> {
  function makeMongoCredentialsFromAWSTemp(creds: AWSTempCredentials) {
    // The AWS session token (creds.Token) may or may not be set.
    if (!creds.AccessKeyId || !creds.SecretAccessKey) {
      throw new MongoMissingCredentialsError('Could not obtain temporary MONGODB-AWS credentials');
    }

    return new MongoCredentials({
      username: creds.AccessKeyId,
      password: creds.SecretAccessKey,
      source: credentials.source,
      mechanism: AuthMechanism.MONGODB_AWS,
      mechanismProperties: {
        AWS_SESSION_TOKEN: creds.Token
      }
    });
  }
  const temporaryCredentials = await awsCredentialFetcher.getCredentials();

  return makeMongoCredentialsFromAWSTemp(temporaryCredentials);
}

So, we always have an accessKeyId and secretAccessKey, token is optional.

// 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);
Expand Down Expand Up @@ -114,7 +106,7 @@ export class MongoDBAWS extends AuthProvider {
}

const body = 'Action=GetCallerIdentity&Version=2011-06-15';
const options = sign(
const signed = await aws4Sign(
{
method: 'POST',
host,
Expand All @@ -127,14 +119,15 @@ export class MongoDBAWS extends AuthProvider {
'X-MongoDB-GS2-CB-Flag': 'n'
},
path: '/',
body
body,
date: new Date()
},
awsCredentials
);

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) {
Expand Down
62 changes: 1 addition & 61 deletions src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface AWSCredentials {
expiration?: Date;
}

type CredentialProvider = {
export type CredentialProvider = {
fromNodeProviderChain(
this: void,
options: { clientConfig: { region: string } }
Expand Down Expand Up @@ -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')
Expand Down
Loading