Skip to content

Commit 0f46db8

Browse files
feat(NODE-5393): aws4 no longer required for AWS authentication (#4824)
Co-authored-by: Bailey Pearson <bailey.pearson@gmail.com>
1 parent 9768640 commit 0f46db8

File tree

10 files changed

+408
-137
lines changed

10 files changed

+408
-137
lines changed

.evergreen/run-mongodb-aws-ecs-test.sh

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,3 @@ source ./.evergreen/prepare-shell.sh # should not run git clone
1313

1414
# load node.js
1515
source $DRIVERS_TOOLS/.evergreen/init-node-and-npm-env.sh
16-
17-
# run the tests
18-
npm install aws4

.evergreen/setup-mongodb-aws-auth-tests.sh

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,5 @@ cd $DRIVERS_TOOLS/.evergreen/auth_aws
2222

2323
cd $BEFORE
2424

25-
npm install --no-save aws4
26-
2725
# revert to show test output
2826
set -x

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"@types/whatwg-url": "^13.0.0",
8282
"@typescript-eslint/eslint-plugin": "^8.46.3",
8383
"@typescript-eslint/parser": "^8.31.1",
84+
"aws4": "^1.13.2",
8485
"chai": "^4.4.1",
8586
"chai-subset": "^1.6.0",
8687
"chalk": "^4.1.2",

src/cmap/auth/aws4.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { BSON } from '../../bson';
2+
import { type AWSCredentials } from '../../deps';
3+
4+
export type AwsSigv4Options = {
5+
path: '/';
6+
body: string;
7+
host: string;
8+
method: 'POST';
9+
headers: {
10+
'Content-Type': 'application/x-www-form-urlencoded';
11+
'Content-Length': number;
12+
'X-MongoDB-Server-Nonce': string;
13+
'X-MongoDB-GS2-CB-Flag': 'n';
14+
};
15+
service: string;
16+
region: string;
17+
date: Date;
18+
};
19+
20+
export type SignedHeaders = {
21+
Authorization: string;
22+
'X-Amz-Date': string;
23+
};
24+
25+
/**
26+
* Calculates the SHA-256 hash of a string.
27+
*
28+
* @param str - String to hash.
29+
* @returns Hexadecimal representation of the hash.
30+
*/
31+
const getHexSha256 = async (str: string): Promise<string> => {
32+
const data = stringToBuffer(str);
33+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
34+
const hashHex = BSON.onDemand.ByteUtils.toHex(new Uint8Array(hashBuffer));
35+
return hashHex;
36+
};
37+
38+
/**
39+
* Calculates the HMAC-SHA256 of a string using the provided key.
40+
* @param key - Key to use for HMAC calculation. Can be a string or Uint8Array.
41+
* @param str - String to calculate HMAC for.
42+
* @returns Uint8Array containing the HMAC-SHA256 digest.
43+
*/
44+
const getHmacSha256 = async (key: string | Uint8Array, str: string): Promise<Uint8Array> => {
45+
let keyData: Uint8Array;
46+
if (typeof key === 'string') {
47+
keyData = stringToBuffer(key);
48+
} else {
49+
keyData = key;
50+
}
51+
52+
const importedKey = await crypto.subtle.importKey(
53+
'raw',
54+
keyData,
55+
{ name: 'HMAC', hash: { name: 'SHA-256' } },
56+
false,
57+
['sign']
58+
);
59+
const strData = stringToBuffer(str);
60+
const signature = await crypto.subtle.sign('HMAC', importedKey, strData);
61+
const digest = new Uint8Array(signature);
62+
return digest;
63+
};
64+
65+
/**
66+
* Converts header values according to AWS requirements,
67+
* From https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request
68+
* For values, you must:
69+
- trim any leading or trailing spaces.
70+
- convert sequential spaces to a single space.
71+
* @param value - Header value to convert.
72+
* @returns - Converted header value.
73+
*/
74+
const convertHeaderValue = (value: string | number) => {
75+
return value.toString().trim().replace(/\s+/g, ' ');
76+
};
77+
78+
/**
79+
* Returns a Uint8Array representation of a string, encoded in UTF-8.
80+
* @param str - String to convert.
81+
* @returns Uint8Array containing the UTF-8 encoded string.
82+
*/
83+
function stringToBuffer(str: string): Uint8Array {
84+
const data = new Uint8Array(BSON.onDemand.ByteUtils.utf8ByteLength(str));
85+
BSON.onDemand.ByteUtils.encodeUTF8Into(data, str, 0);
86+
return data;
87+
}
88+
89+
/**
90+
* This method implements AWS Signature 4 logic for a very specific request format.
91+
* The signing logic is described here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
92+
*/
93+
export async function aws4Sign(
94+
options: AwsSigv4Options,
95+
credentials: AWSCredentials
96+
): Promise<SignedHeaders> {
97+
/**
98+
* From the spec: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
99+
*
100+
* Summary of signing steps
101+
* 1. Create a canonical request
102+
* 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.
103+
* 2. Create a hash of the canonical request
104+
* 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.
105+
* 3. Create a string to sign
106+
* 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.
107+
* 4. Derive a signing key
108+
* Use the secret access key to derive the key used to sign the request.
109+
* 5. Calculate the signature
110+
* Perform a keyed hash operation on the string to sign using the derived signing key as the hash key.
111+
* 6. Add the signature to the request
112+
* Add the calculated signature to an HTTP header or to the query string of the request.
113+
*/
114+
115+
// 1: Create a canonical request
116+
117+
// Date – The date and time used to sign the request.
118+
const date = options.date;
119+
// RequestDateTime – The date and time used in the credential scope. This value is the current UTC time in ISO 8601 format (for example, 20130524T000000Z).
120+
const requestDateTime = date.toISOString().replace(/[:-]|\.\d{3}/g, '');
121+
// RequestDate – The date used in the credential scope. This value is the current UTC date in YYYYMMDD format (for example, 20130524).
122+
const requestDate = requestDateTime.substring(0, 8);
123+
// Method – The HTTP request method. For us, this is always 'POST'.
124+
const method = options.method;
125+
// 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
126+
// For our requests, this is always '/'
127+
const canonicalUri = options.path;
128+
// CanonicalQueryString – The URI-encoded query string parameters. For our requests, there are no query string parameters, so this is always an empty string.
129+
const canonicalQuerystring = '';
130+
131+
// CanonicalHeaders – A list of request headers with their values. Individual header name and value pairs are separated by the newline character ("\n").
132+
// All of our known/expected headers are included here, there are no extra headers.
133+
const headers = new Headers({
134+
'content-length': convertHeaderValue(options.headers['Content-Length']),
135+
'content-type': convertHeaderValue(options.headers['Content-Type']),
136+
host: convertHeaderValue(options.host),
137+
'x-amz-date': convertHeaderValue(requestDateTime),
138+
'x-mongodb-gs2-cb-flag': convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag']),
139+
'x-mongodb-server-nonce': convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])
140+
});
141+
// If session token is provided, include it in the headers
142+
if ('sessionToken' in credentials && credentials.sessionToken) {
143+
headers.append('x-amz-security-token', convertHeaderValue(credentials.sessionToken));
144+
}
145+
146+
// Canonical headers are lowercased and sorted.
147+
const canonicalHeaders = Array.from(headers.entries())
148+
.map(([key, value]) => `${key.toLowerCase()}:${value}`)
149+
.sort()
150+
.join('\n');
151+
const canonicalHeaderNames = Array.from(headers.keys()).map(header => header.toLowerCase());
152+
// SignedHeaders – An alphabetically sorted, semicolon-separated list of lowercase request header names.
153+
const signedHeaders = canonicalHeaderNames.sort().join(';');
154+
155+
// 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.
156+
const hashedPayload = await getHexSha256(options.body);
157+
158+
// CanonicalRequest – A string that includes the above elements, separated by newline characters.
159+
const canonicalRequest = [
160+
method,
161+
canonicalUri,
162+
canonicalQuerystring,
163+
canonicalHeaders + '\n',
164+
signedHeaders,
165+
hashedPayload
166+
].join('\n');
167+
168+
// 2. Create a hash of the canonical request
169+
// HashedCanonicalRequest – A string created by using the canonical request as input to a hash function.
170+
const hashedCanonicalRequest = await getHexSha256(canonicalRequest);
171+
172+
// 3. Create a string to sign
173+
// Algorithm – The algorithm used to create the hash of the canonical request. For SigV4, use AWS4-HMAC-SHA256.
174+
const algorithm = 'AWS4-HMAC-SHA256';
175+
// CredentialScope – The credential scope, which restricts the resulting signature to the specified Region and service.
176+
// Has the following format: YYYYMMDD/region/service/aws4_request.
177+
const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`;
178+
// StringToSign – A string that includes the above elements, separated by newline characters.
179+
const stringToSign = [algorithm, requestDateTime, credentialScope, hashedCanonicalRequest].join(
180+
'\n'
181+
);
182+
183+
// 4. Derive a signing key
184+
// 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.
185+
const dateKey = await getHmacSha256('AWS4' + credentials.secretAccessKey, requestDate);
186+
const dateRegionKey = await getHmacSha256(dateKey, options.region);
187+
const dateRegionServiceKey = await getHmacSha256(dateRegionKey, options.service);
188+
const signingKey = await getHmacSha256(dateRegionServiceKey, 'aws4_request');
189+
190+
// 5. Calculate the signature
191+
const signatureBuffer = await getHmacSha256(signingKey, stringToSign);
192+
const signature = BSON.onDemand.ByteUtils.toHex(signatureBuffer);
193+
194+
// 6. Add the signature to the request
195+
// Calculate the Authorization header
196+
const authorizationHeader = [
197+
'AWS4-HMAC-SHA256 Credential=' + credentials.accessKeyId + '/' + credentialScope,
198+
'SignedHeaders=' + signedHeaders,
199+
'Signature=' + signature
200+
].join(', ');
201+
202+
// Return the calculated headers
203+
return {
204+
Authorization: authorizationHeader,
205+
'X-Amz-Date': requestDateTime
206+
};
207+
}

src/cmap/auth/mongodb_aws.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Binary, BSONSerializeOptions } from '../../bson';
22
import * as BSON from '../../bson';
3-
import { aws4 } from '../../deps';
43
import {
54
MongoCompatibilityError,
65
MongoMissingCredentialsError,
@@ -13,6 +12,7 @@ import {
1312
AWSSDKCredentialProvider,
1413
type AWSTempCredentials
1514
} from './aws_temporary_credentials';
15+
import { aws4Sign } from './aws4';
1616
import { MongoCredentials } from './mongo_credentials';
1717
import { AuthMechanism } from './providers';
1818

@@ -45,11 +45,6 @@ export class MongoDBAWS extends AuthProvider {
4545
throw new MongoMissingCredentialsError('AuthContext must provide credentials.');
4646
}
4747

48-
if ('kModuleError' in aws4) {
49-
throw aws4['kModuleError'];
50-
}
51-
const { sign } = aws4;
52-
5348
if (maxWireVersion(connection) < 9) {
5449
throw new MongoCompatibilityError(
5550
'MONGODB-AWS authentication requires MongoDB version 4.4 or later'
@@ -68,13 +63,10 @@ export class MongoDBAWS extends AuthProvider {
6863
// Allow the user to specify an AWS session token for authentication with temporary credentials.
6964
const sessionToken = credentials.mechanismProperties.AWS_SESSION_TOKEN;
7065

71-
// If all three defined, include sessionToken, else include username and pass, else no credentials
72-
const awsCredentials =
73-
accessKeyId && secretAccessKey && sessionToken
74-
? { accessKeyId, secretAccessKey, sessionToken }
75-
: accessKeyId && secretAccessKey
76-
? { accessKeyId, secretAccessKey }
77-
: undefined;
66+
// If all three defined, include sessionToken, else only include username and pass
67+
const awsCredentials = sessionToken
68+
? { accessKeyId, secretAccessKey, sessionToken }
69+
: { accessKeyId, secretAccessKey };
7870

7971
const db = credentials.source;
8072
const nonce = await randomBytes(32);
@@ -114,7 +106,7 @@ export class MongoDBAWS extends AuthProvider {
114106
}
115107

116108
const body = 'Action=GetCallerIdentity&Version=2011-06-15';
117-
const options = sign(
109+
const headers = await aws4Sign(
118110
{
119111
method: 'POST',
120112
host,
@@ -127,14 +119,15 @@ export class MongoDBAWS extends AuthProvider {
127119
'X-MongoDB-GS2-CB-Flag': 'n'
128120
},
129121
path: '/',
130-
body
122+
body,
123+
date: new Date()
131124
},
132125
awsCredentials
133126
);
134127

135128
const payload: AWSSaslContinuePayload = {
136-
a: options.headers.Authorization,
137-
d: options.headers['X-Amz-Date']
129+
a: headers.Authorization,
130+
d: headers['X-Amz-Date']
138131
};
139132

140133
if (sessionToken) {

src/deps.ts

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -203,66 +203,6 @@ export function getSocks(): SocksLib | { kModuleError: MongoMissingDependencyErr
203203
}
204204
}
205205

206-
interface AWS4 {
207-
/**
208-
* Created these inline types to better assert future usage of this API
209-
* @param options - options for request
210-
* @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y
211-
*/
212-
sign(
213-
this: void,
214-
options: {
215-
path: '/';
216-
body: string;
217-
host: string;
218-
method: 'POST';
219-
headers: {
220-
'Content-Type': 'application/x-www-form-urlencoded';
221-
'Content-Length': number;
222-
'X-MongoDB-Server-Nonce': string;
223-
'X-MongoDB-GS2-CB-Flag': 'n';
224-
};
225-
service: string;
226-
region: string;
227-
},
228-
credentials:
229-
| {
230-
accessKeyId: string;
231-
secretAccessKey: string;
232-
sessionToken: string;
233-
}
234-
| {
235-
accessKeyId: string;
236-
secretAccessKey: string;
237-
}
238-
| undefined
239-
): {
240-
headers: {
241-
Authorization: string;
242-
'X-Amz-Date': string;
243-
};
244-
};
245-
}
246-
247-
export const aws4: AWS4 | { kModuleError: MongoMissingDependencyError } = loadAws4();
248-
249-
function loadAws4() {
250-
let aws4: AWS4 | { kModuleError: MongoMissingDependencyError };
251-
try {
252-
// eslint-disable-next-line @typescript-eslint/no-require-imports
253-
aws4 = require('aws4');
254-
} catch (error) {
255-
aws4 = makeErrorModule(
256-
new MongoMissingDependencyError(
257-
'Optional module `aws4` not found. Please install it to enable AWS authentication',
258-
{ cause: error, dependencyName: 'aws4' }
259-
)
260-
);
261-
}
262-
263-
return aws4;
264-
}
265-
266206
/** A utility function to get the instance of mongodb-client-encryption, if it exists. */
267207
export function getMongoDBClientEncryption():
268208
| typeof import('mongodb-client-encryption')

0 commit comments

Comments
 (0)