You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
336 lines
9.5 KiB
336 lines
9.5 KiB
import * as crypto from 'crypto';
|
|
import * as http from 'http';
|
|
import * as url from 'url';
|
|
|
|
import type { Binary, BSONSerializeOptions } from '../../bson';
|
|
import * as BSON from '../../bson';
|
|
import { aws4, getAwsCredentialProvider } from '../../deps';
|
|
import {
|
|
MongoAWSError,
|
|
MongoCompatibilityError,
|
|
MongoMissingCredentialsError,
|
|
MongoRuntimeError
|
|
} from '../../error';
|
|
import { ByteUtils, Callback, maxWireVersion, ns } from '../../utils';
|
|
import { AuthContext, AuthProvider } from './auth_provider';
|
|
import { MongoCredentials } from './mongo_credentials';
|
|
import { AuthMechanism } from './providers';
|
|
|
|
const ASCII_N = 110;
|
|
const AWS_RELATIVE_URI = 'http://169.254.170.2';
|
|
const AWS_EC2_URI = 'http://169.254.169.254';
|
|
const AWS_EC2_PATH = '/latest/meta-data/iam/security-credentials';
|
|
const bsonOptions: BSONSerializeOptions = {
|
|
promoteLongs: true,
|
|
promoteValues: true,
|
|
promoteBuffers: false,
|
|
bsonRegExp: false
|
|
};
|
|
|
|
interface AWSSaslContinuePayload {
|
|
a: string;
|
|
d: string;
|
|
t?: string;
|
|
}
|
|
|
|
export class MongoDBAWS extends AuthProvider {
|
|
override auth(authContext: AuthContext, callback: Callback): void {
|
|
const { connection, credentials } = authContext;
|
|
if (!credentials) {
|
|
return callback(new MongoMissingCredentialsError('AuthContext must provide credentials.'));
|
|
}
|
|
|
|
if ('kModuleError' in aws4) {
|
|
return callback(aws4['kModuleError']);
|
|
}
|
|
const { sign } = aws4;
|
|
|
|
if (maxWireVersion(connection) < 9) {
|
|
callback(
|
|
new MongoCompatibilityError(
|
|
'MONGODB-AWS authentication requires MongoDB version 4.4 or later'
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!credentials.username) {
|
|
makeTempCredentials(credentials, (err, tempCredentials) => {
|
|
if (err || !tempCredentials) return callback(err);
|
|
|
|
authContext.credentials = tempCredentials;
|
|
this.auth(authContext, callback);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const accessKeyId = credentials.username;
|
|
const secretAccessKey = credentials.password;
|
|
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;
|
|
|
|
const db = credentials.source;
|
|
crypto.randomBytes(32, (err, nonce) => {
|
|
if (err) {
|
|
callback(err);
|
|
return;
|
|
}
|
|
|
|
const saslStart = {
|
|
saslStart: 1,
|
|
mechanism: 'MONGODB-AWS',
|
|
payload: BSON.serialize({ r: nonce, p: ASCII_N }, bsonOptions)
|
|
};
|
|
|
|
connection.command(ns(`${db}.$cmd`), saslStart, undefined, (err, res) => {
|
|
if (err) return callback(err);
|
|
|
|
const serverResponse = BSON.deserialize(res.payload.buffer, bsonOptions) as {
|
|
s: Binary;
|
|
h: string;
|
|
};
|
|
const host = serverResponse.h;
|
|
const serverNonce = serverResponse.s.buffer;
|
|
if (serverNonce.length !== 64) {
|
|
callback(
|
|
// TODO(NODE-3483)
|
|
new MongoRuntimeError(`Invalid server nonce length ${serverNonce.length}, expected 64`)
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!ByteUtils.equals(serverNonce.subarray(0, nonce.byteLength), nonce)) {
|
|
// throw because the serverNonce's leading 32 bytes must equal the client nonce's 32 bytes
|
|
// https://github.com/mongodb/specifications/blob/875446db44aade414011731840831f38a6c668df/source/auth/auth.rst#id11
|
|
|
|
// TODO(NODE-3483)
|
|
callback(new MongoRuntimeError('Server nonce does not begin with client nonce'));
|
|
return;
|
|
}
|
|
|
|
if (host.length < 1 || host.length > 255 || host.indexOf('..') !== -1) {
|
|
// TODO(NODE-3483)
|
|
callback(new MongoRuntimeError(`Server returned an invalid host: "${host}"`));
|
|
return;
|
|
}
|
|
|
|
const body = 'Action=GetCallerIdentity&Version=2011-06-15';
|
|
const options = sign(
|
|
{
|
|
method: 'POST',
|
|
host,
|
|
region: deriveRegion(serverResponse.h),
|
|
service: 'sts',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Content-Length': body.length,
|
|
'X-MongoDB-Server-Nonce': ByteUtils.toBase64(serverNonce),
|
|
'X-MongoDB-GS2-CB-Flag': 'n'
|
|
},
|
|
path: '/',
|
|
body
|
|
},
|
|
awsCredentials
|
|
);
|
|
|
|
const payload: AWSSaslContinuePayload = {
|
|
a: options.headers.Authorization,
|
|
d: options.headers['X-Amz-Date']
|
|
};
|
|
if (sessionToken) {
|
|
payload.t = sessionToken;
|
|
}
|
|
|
|
const saslContinue = {
|
|
saslContinue: 1,
|
|
conversationId: 1,
|
|
payload: BSON.serialize(payload, bsonOptions)
|
|
};
|
|
|
|
connection.command(ns(`${db}.$cmd`), saslContinue, undefined, callback);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
interface AWSTempCredentials {
|
|
AccessKeyId?: string;
|
|
SecretAccessKey?: string;
|
|
Token?: string;
|
|
RoleArn?: string;
|
|
Expiration?: Date;
|
|
}
|
|
|
|
/* @internal */
|
|
export interface AWSCredentials {
|
|
accessKeyId?: string;
|
|
secretAccessKey?: string;
|
|
sessionToken?: string;
|
|
expiration?: Date;
|
|
}
|
|
|
|
function makeTempCredentials(credentials: MongoCredentials, callback: Callback<MongoCredentials>) {
|
|
function done(creds: AWSTempCredentials) {
|
|
if (!creds.AccessKeyId || !creds.SecretAccessKey || !creds.Token) {
|
|
callback(
|
|
new MongoMissingCredentialsError('Could not obtain temporary MONGODB-AWS credentials')
|
|
);
|
|
return;
|
|
}
|
|
|
|
callback(
|
|
undefined,
|
|
new MongoCredentials({
|
|
username: creds.AccessKeyId,
|
|
password: creds.SecretAccessKey,
|
|
source: credentials.source,
|
|
mechanism: AuthMechanism.MONGODB_AWS,
|
|
mechanismProperties: {
|
|
AWS_SESSION_TOKEN: creds.Token
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
const credentialProvider = getAwsCredentialProvider();
|
|
|
|
// Check if the AWS credential provider from the SDK is present. If not,
|
|
// use the old method.
|
|
if ('kModuleError' in credentialProvider) {
|
|
// If the environment variable AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
|
|
// is set then drivers MUST assume that it was set by an AWS ECS agent
|
|
if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
|
|
request(
|
|
`${AWS_RELATIVE_URI}${process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`,
|
|
undefined,
|
|
(err, res) => {
|
|
if (err) return callback(err);
|
|
done(res);
|
|
}
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
// Otherwise assume we are on an EC2 instance
|
|
|
|
// get a token
|
|
request(
|
|
`${AWS_EC2_URI}/latest/api/token`,
|
|
{ method: 'PUT', json: false, headers: { 'X-aws-ec2-metadata-token-ttl-seconds': 30 } },
|
|
(err, token) => {
|
|
if (err) return callback(err);
|
|
|
|
// get role name
|
|
request(
|
|
`${AWS_EC2_URI}/${AWS_EC2_PATH}`,
|
|
{ json: false, headers: { 'X-aws-ec2-metadata-token': token } },
|
|
(err, roleName) => {
|
|
if (err) return callback(err);
|
|
|
|
// get temp credentials
|
|
request(
|
|
`${AWS_EC2_URI}/${AWS_EC2_PATH}/${roleName}`,
|
|
{ headers: { 'X-aws-ec2-metadata-token': token } },
|
|
(err, creds) => {
|
|
if (err) return callback(err);
|
|
done(creds);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
} else {
|
|
/*
|
|
* Creates a credential provider that will attempt to find credentials from the
|
|
* following sources (listed in order of precedence):
|
|
*
|
|
* - Environment variables exposed via process.env
|
|
* - SSO credentials from token cache
|
|
* - Web identity token credentials
|
|
* - Shared credentials and config ini files
|
|
* - The EC2/ECS Instance Metadata Service
|
|
*/
|
|
const { fromNodeProviderChain } = credentialProvider;
|
|
const provider = fromNodeProviderChain();
|
|
provider()
|
|
.then((creds: AWSCredentials) => {
|
|
done({
|
|
AccessKeyId: creds.accessKeyId,
|
|
SecretAccessKey: creds.secretAccessKey,
|
|
Token: creds.sessionToken,
|
|
Expiration: creds.expiration
|
|
});
|
|
})
|
|
.catch((error: Error) => {
|
|
callback(new MongoAWSError(error.message));
|
|
});
|
|
}
|
|
}
|
|
|
|
function deriveRegion(host: string) {
|
|
const parts = host.split('.');
|
|
if (parts.length === 1 || parts[1] === 'amazonaws') {
|
|
return 'us-east-1';
|
|
}
|
|
|
|
return parts[1];
|
|
}
|
|
|
|
interface RequestOptions {
|
|
json?: boolean;
|
|
method?: string;
|
|
timeout?: number;
|
|
headers?: http.OutgoingHttpHeaders;
|
|
}
|
|
|
|
function request(uri: string, _options: RequestOptions | undefined, callback: Callback) {
|
|
const options = Object.assign(
|
|
{
|
|
method: 'GET',
|
|
timeout: 10000,
|
|
json: true
|
|
},
|
|
url.parse(uri),
|
|
_options
|
|
);
|
|
|
|
const req = http.request(options, res => {
|
|
res.setEncoding('utf8');
|
|
|
|
let data = '';
|
|
res.on('data', d => (data += d));
|
|
res.on('end', () => {
|
|
if (options.json === false) {
|
|
callback(undefined, data);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
callback(undefined, parsed);
|
|
} catch (err) {
|
|
// TODO(NODE-3483)
|
|
callback(new MongoRuntimeError(`Invalid JSON response: "${data}"`));
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy(new MongoAWSError(`AWS request to ${uri} timed out after ${options.timeout} ms`));
|
|
});
|
|
|
|
req.on('error', err => callback(err));
|
|
req.end();
|
|
}
|