From b3482f32551ea5fcfefa861eca52961a99c82fe3 Mon Sep 17 00:00:00 2001 From: Durran Jordan Date: Tue, 6 Jun 2023 21:52:33 +0200 Subject: [PATCH] feat(NODE-4929): Add OIDC Azure workflow (#3670) --- .evergreen/ci_matrix_constants.js | 2 + .evergreen/config.in.yml | 42 ++++ .evergreen/config.yml | 47 ++++ .evergreen/generate_evergreen_tasks.js | 9 + .evergreen/run-oidc-tests-azure.sh | 11 + .evergreen/run-oidc-tests.sh | 30 ++- package.json | 1 + src/cmap/auth/mongo_credentials.ts | 24 +- src/cmap/auth/mongodb_aws.ts | 62 +----- src/cmap/auth/mongodb_oidc.ts | 4 +- .../mongodb_oidc/azure_service_workflow.ts | 86 +++++++ .../auth/mongodb_oidc/azure_token_cache.ts | 51 +++++ src/cmap/auth/mongodb_oidc/cache.ts | 38 +++- .../auth/mongodb_oidc/callback_lock_cache.ts | 13 +- .../auth/mongodb_oidc/callback_workflow.ts | 2 +- .../auth/mongodb_oidc/service_workflow.ts | 6 +- .../auth/mongodb_oidc/token_entry_cache.ts | 32 +-- src/connection_string.ts | 2 +- src/error.ts | 17 ++ src/index.ts | 1 + src/utils.ts | 68 ++++++ .../auth/mongodb_oidc_azure.prose.test.ts | 209 ++++++++++++++++++ test/mongodb.ts | 2 + test/tools/runner/config.ts | 4 + test/tools/runner/hooks/configuration.js | 1 + .../mongodb_oidc/azure_token_cache.test.ts | 77 +++++++ .../mongodb_oidc/callback_lock_cache.test.ts | 6 +- .../mongodb_oidc/token_entry_cache.test.ts | 4 +- test/unit/connection_string.test.ts | 16 ++ test/unit/index.test.ts | 1 + 30 files changed, 763 insertions(+), 105 deletions(-) create mode 100644 .evergreen/run-oidc-tests-azure.sh mode change 100644 => 100755 .evergreen/run-oidc-tests.sh create mode 100644 src/cmap/auth/mongodb_oidc/azure_service_workflow.ts create mode 100644 src/cmap/auth/mongodb_oidc/azure_token_cache.ts create mode 100644 test/integration/auth/mongodb_oidc_azure.prose.test.ts create mode 100644 test/unit/cmap/auth/mongodb_oidc/azure_token_cache.test.ts diff --git a/.evergreen/ci_matrix_constants.js b/.evergreen/ci_matrix_constants.js index 081a4082be..8e9578c9fe 100644 --- a/.evergreen/ci_matrix_constants.js +++ b/.evergreen/ci_matrix_constants.js @@ -17,6 +17,7 @@ const DEFAULT_OS = 'rhel80-large'; const WINDOWS_OS = 'windows-vsCurrent-large'; const MACOS_OS = 'macos-1100'; const UBUNTU_OS = 'ubuntu1804-large'; +const UBUNTU_20_OS = 'ubuntu2004-small' const DEBIAN_OS = 'debian11-small'; module.exports = { @@ -32,5 +33,6 @@ module.exports = { WINDOWS_OS, MACOS_OS, UBUNTU_OS, + UBUNTU_20_OS, DEBIAN_OS }; diff --git a/.evergreen/config.in.yml b/.evergreen/config.in.yml index 1b65761e3f..dce18afede 100644 --- a/.evergreen/config.in.yml +++ b/.evergreen/config.in.yml @@ -1244,6 +1244,20 @@ tasks: args: - src/.evergreen/run-azure-kms-tests.sh + - name: "oidc-auth-test-azure-latest" + commands: + - func: "install dependencies" + - command: subprocess.exec + params: + working_dir: src + binary: bash + env: + DRIVERS_TOOLS: ${DRIVERS_TOOLS} + PROJECT_DIRECTORY: ${PROJECT_DIRECTORY} + AZUREOIDC_CLIENTID: ${testazureoidc_clientid} + PROVIDER_NAME: azure + args: + - .evergreen/run-oidc-tests-azure.sh task_groups: - name: serverless_task_group @@ -1348,6 +1362,34 @@ task_groups: tasks: - test-azurekms-task + - name: testazureoidc_task_group + setup_group: + - func: fetch source + - command: shell.exec + params: + shell: bash + script: |- + set -o errexit + ${PREPARE_SHELL} + export AZUREOIDC_CLIENTID="${testazureoidc_clientid}" + export AZUREOIDC_TENANTID="${testazureoic_tenantid}" + export AZUREOIDC_SECRET="${testazureoidc_secret}" + export AZUREOIDC_KEYVAULT=${testazureoidc_keyvault} + export AZUREOIDC_DRIVERS_TOOLS="$DRIVERS_TOOLS" + export AZUREOIDC_VMNAME_PREFIX="NODE_DRIVER" + $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/create-and-setup-vm.sh + teardown_group: + - command: shell.exec + params: + shell: bash + script: |- + ${PREPARE_SHELL} + $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/delete-vm.sh + setup_group_can_fail_task: true + setup_group_timeout_secs: 1800 + tasks: + - oidc-auth-test-azure-latest + pre: - func: "fetch source" - func: "windows fix" diff --git a/.evergreen/config.yml b/.evergreen/config.yml index 540957dddb..e5aac2fa91 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -1168,6 +1168,20 @@ tasks: EXPECTED_AZUREKMS_OUTCOME: failure args: - src/.evergreen/run-azure-kms-tests.sh + - name: oidc-auth-test-azure-latest + commands: + - func: install dependencies + - command: subprocess.exec + params: + working_dir: src + binary: bash + env: + DRIVERS_TOOLS: ${DRIVERS_TOOLS} + PROJECT_DIRECTORY: ${PROJECT_DIRECTORY} + AZUREOIDC_CLIENTID: ${testazureoidc_clientid} + PROVIDER_NAME: azure + args: + - .evergreen/run-oidc-tests-azure.sh - name: test-latest-server tags: - latest @@ -3420,6 +3434,33 @@ task_groups: - ${DRIVERS_TOOLS}/.evergreen/csfle/azurekms/delete-vm.sh tasks: - test-azurekms-task + - name: testazureoidc_task_group + setup_group: + - func: fetch source + - command: shell.exec + params: + shell: bash + script: |- + set -o errexit + ${PREPARE_SHELL} + export AZUREOIDC_CLIENTID="${testazureoidc_clientid}" + export AZUREOIDC_TENANTID="${testazureoic_tenantid}" + export AZUREOIDC_SECRET="${testazureoidc_secret}" + export AZUREOIDC_KEYVAULT=${testazureoidc_keyvault} + export AZUREOIDC_DRIVERS_TOOLS="$DRIVERS_TOOLS" + export AZUREOIDC_VMNAME_PREFIX="NODE_DRIVER" + $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/create-and-setup-vm.sh + teardown_group: + - command: shell.exec + params: + shell: bash + script: |- + ${PREPARE_SHELL} + $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/delete-vm.sh + setup_group_can_fail_task: true + setup_group_timeout_secs: 1800 + tasks: + - oidc-auth-test-azure-latest pre: - func: fetch source - func: windows fix @@ -3999,6 +4040,12 @@ buildvariants: tasks: - test_azurekms_task_group - test-azurekms-fail-task + - name: ubuntu20-test-azure-oidc + display_name: Azure OIDC + run_on: ubuntu2004-small + batchtime: 20160 + tasks: + - testazureoidc_task_group - name: rhel8-no-auth-tests display_name: No Auth Tests run_on: rhel80-large diff --git a/.evergreen/generate_evergreen_tasks.js b/.evergreen/generate_evergreen_tasks.js index 78f660cc3c..74b3f98148 100644 --- a/.evergreen/generate_evergreen_tasks.js +++ b/.evergreen/generate_evergreen_tasks.js @@ -16,6 +16,7 @@ const { WINDOWS_OS, MACOS_OS, UBUNTU_OS, + UBUNTU_20_OS, DEBIAN_OS } = require('./ci_matrix_constants'); @@ -754,6 +755,14 @@ BUILD_VARIANTS.push({ tasks: ['test_azurekms_task_group', 'test-azurekms-fail-task'] }); +BUILD_VARIANTS.push({ + name: 'ubuntu20-test-azure-oidc', + display_name: 'Azure OIDC', + run_on: UBUNTU_20_OS, + batchtime: 20160, + tasks: ['testazureoidc_task_group'] +}); + BUILD_VARIANTS.push({ name: 'rhel8-no-auth-tests', display_name: 'No Auth Tests', diff --git a/.evergreen/run-oidc-tests-azure.sh b/.evergreen/run-oidc-tests-azure.sh new file mode 100644 index 0000000000..6e65bff3f4 --- /dev/null +++ b/.evergreen/run-oidc-tests-azure.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -o xtrace # Write all commands first to stderr +set -o errexit # Exit the script with error if any of the commands fail + +export AZUREOIDC_DRIVERS_TAR_FILE=/tmp/node-mongodb-native.tgz +tar czf $AZUREOIDC_DRIVERS_TAR_FILE . +export AZUREOIDC_TEST_CMD="source ./env.sh && PROVIDER_NAME=azure ./.evergreen/run-oidc-tests.sh" +export AZUREOIDC_CLIENTID=$AZUREOIDC_CLIENTID +export PROJECT_DIRECTORY=$PROJECT_DIRECTORY +export PROVIDER_NAME=$PROVIDER_NAME +bash $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/run-driver-test.sh \ No newline at end of file diff --git a/.evergreen/run-oidc-tests.sh b/.evergreen/run-oidc-tests.sh old mode 100644 new mode 100755 index 2a4892ddf6..98881a0c2d --- a/.evergreen/run-oidc-tests.sh +++ b/.evergreen/run-oidc-tests.sh @@ -2,14 +2,34 @@ set -o errexit # Exit the script with error if any of the commands fail set -o xtrace # Write all commands first to stderr +PROVIDER_NAME=${PROVIDER_NAME:-"aws"} +PROJECT_DIRECTORY=${PROJECT_DIRECTORY:-"."} source "${PROJECT_DIRECTORY}/.evergreen/init-node-and-npm-env.sh" MONGODB_URI=${MONGODB_URI:-"mongodb://127.0.0.1:27017"} -MONGODB_URI_SINGLE="${MONGODB_URI}/?authMechanism=MONGODB-OIDC&authMechanismProperties=DEVICE_NAME:aws" -echo $MONGODB_URI_SINGLE - -export MONGODB_URI="$MONGODB_URI_SINGLE" export OIDC_TOKEN_DIR=${OIDC_TOKEN_DIR} -npm run check:oidc +export MONGODB_URI=${MONGODB_URI:-"mongodb://localhost"} + +if [ "$PROVIDER_NAME" = "aws" ]; then + export MONGODB_URI_SINGLE="${MONGODB_URI}/?authMechanism=MONGODB-OIDC" + export MONGODB_URI_MULTIPLE="${MONGODB_URI}:27018/?authMechanism=MONGODB-OIDC&directConnection=true" + + if [ -z "${OIDC_TOKEN_DIR}" ]; then + echo "Must specify OIDC_TOKEN_DIR" + exit 1 + fi + npm run check:oidc +elif [ "$PROVIDER_NAME" = "azure" ]; then + if [ -z "${AZUREOIDC_CLIENTID}" ]; then + echo "Must specify an AZUREOIDC_CLIENTID" + exit 1 + fi + MONGODB_URI="${MONGODB_URI}/?authMechanism=MONGODB-OIDC" + MONGODB_URI="${MONGODB_URI}&authMechanismProperties=PROVIDER_NAME:azure" + export MONGODB_URI="${MONGODB_URI},TOKEN_AUDIENCE:api%3A%2F%2F${AZUREOIDC_CLIENTID}" + npm run check:oidc-azure +else + npm run check:oidc +fi diff --git a/package.json b/package.json index bccad6a1a6..bbc1d35ceb 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "check:adl": "mocha --config test/mocha_mongodb.json test/manual/atlas-data-lake-testing", "check:aws": "nyc mocha --config test/mocha_mongodb.json test/integration/auth/mongodb_aws.test.ts", "check:oidc": "mocha --config test/mocha_mongodb.json test/manual/mongodb_oidc.prose.test.ts", + "check:oidc-azure": "mocha --config test/mocha_mongodb.json test/integration/auth/mongodb_oidc_azure.prose.test.ts", "check:ocsp": "mocha --config test/manual/mocharc.json test/manual/ocsp_support.test.js", "check:kerberos": "nyc mocha --config test/manual/mocharc.json test/manual/kerberos.test.ts", "check:tls": "mocha --config test/manual/mocharc.json test/manual/tls_support.test.js", diff --git a/src/cmap/auth/mongo_credentials.ts b/src/cmap/auth/mongo_credentials.ts index 9239cc171b..150a084168 100644 --- a/src/cmap/auth/mongo_credentials.ts +++ b/src/cmap/auth/mongo_credentials.ts @@ -1,7 +1,9 @@ // Resolves the default auth mechanism according to +// Resolves the default auth mechanism according to import type { Document } from '../../bson'; import { MongoAPIError, + MongoAzureError, MongoInvalidArgumentError, MongoMissingCredentialsError } from '../../error'; @@ -30,6 +32,7 @@ function getDefaultAuthMechanism(hello?: Document): AuthMechanism { return AuthMechanism.MONGODB_CR; } +const ALLOWED_PROVIDER_NAMES: AuthMechanismProperties['PROVIDER_NAME'][] = ['aws', 'azure']; const ALLOWED_HOSTS_ERROR = 'Auth mechanism property ALLOWED_HOSTS must be an array of strings.'; /** @internal */ @@ -42,6 +45,10 @@ export const DEFAULT_ALLOWED_HOSTS = [ '::1' ]; +/** Error for when the token audience is missing in the environment. */ +const TOKEN_AUDIENCE_MISSING_ERROR = + 'TOKEN_AUDIENCE must be set in the auth mechanism properties when PROVIDER_NAME is azure.'; + /** @public */ export interface AuthMechanismProperties extends Document { SERVICE_HOST?: string; @@ -54,9 +61,11 @@ export interface AuthMechanismProperties extends Document { /** @experimental */ REFRESH_TOKEN_CALLBACK?: OIDCRefreshFunction; /** @experimental */ - PROVIDER_NAME?: 'aws'; + PROVIDER_NAME?: 'aws' | 'azure'; /** @experimental */ ALLOWED_HOSTS?: string[]; + /** @experimental */ + TOKEN_AUDIENCE?: string; } /** @public */ @@ -176,12 +185,21 @@ export class MongoCredentials { ); } + if ( + this.mechanismProperties.PROVIDER_NAME === 'azure' && + !this.mechanismProperties.TOKEN_AUDIENCE + ) { + throw new MongoAzureError(TOKEN_AUDIENCE_MISSING_ERROR); + } + if ( this.mechanismProperties.PROVIDER_NAME && - this.mechanismProperties.PROVIDER_NAME !== 'aws' + !ALLOWED_PROVIDER_NAMES.includes(this.mechanismProperties.PROVIDER_NAME) ) { throw new MongoInvalidArgumentError( - `Currently only a PROVIDER_NAME of 'aws' is supported for mechanism '${this.mechanism}'.` + `Currently only a PROVIDER_NAME in ${ALLOWED_PROVIDER_NAMES.join( + ',' + )} is supported for mechanism '${this.mechanism}'.` ); } diff --git a/src/cmap/auth/mongodb_aws.ts b/src/cmap/auth/mongodb_aws.ts index 775979c5c6..57e3a028ff 100644 --- a/src/cmap/auth/mongodb_aws.ts +++ b/src/cmap/auth/mongodb_aws.ts @@ -1,6 +1,4 @@ import * as crypto from 'crypto'; -import * as http from 'http'; -import * as url from 'url'; import { promisify } from 'util'; import type { Binary, BSONSerializeOptions } from '../../bson'; @@ -12,7 +10,7 @@ import { MongoMissingCredentialsError, MongoRuntimeError } from '../../error'; -import { ByteUtils, maxWireVersion, ns } from '../../utils'; +import { ByteUtils, maxWireVersion, ns, request } from '../../utils'; import { type AuthContext, AuthProvider } from './auth_provider'; import { MongoCredentials } from './mongo_credentials'; import { AuthMechanism } from './providers'; @@ -253,61 +251,3 @@ function deriveRegion(host: string) { return parts[1]; } - -interface RequestOptions { - json?: boolean; - method?: string; - timeout?: number; - headers?: http.OutgoingHttpHeaders; -} - -async function request(uri: string): Promise>; -async function request( - uri: string, - options?: { json?: true } & RequestOptions -): Promise>; -async function request(uri: string, options?: { json: false } & RequestOptions): Promise; -async function request( - uri: string, - options: RequestOptions = {} -): Promise> { - return new Promise>((resolve, reject) => { - const requestOptions = { - method: 'GET', - timeout: 10000, - json: true, - ...url.parse(uri), - ...options - }; - - const req = http.request(requestOptions, res => { - res.setEncoding('utf8'); - - let data = ''; - res.on('data', d => { - data += d; - }); - - res.once('end', () => { - if (options.json === false) { - resolve(data); - return; - } - - try { - const parsed = JSON.parse(data); - resolve(parsed); - } catch { - // TODO(NODE-3483) - reject(new MongoRuntimeError(`Invalid JSON response: "${data}"`)); - } - }); - }); - - req.once('timeout', () => - req.destroy(new MongoAWSError(`AWS request to ${uri} timed out after ${options.timeout} ms`)) - ); - req.once('error', error => reject(error)); - req.end(); - }); -} diff --git a/src/cmap/auth/mongodb_oidc.ts b/src/cmap/auth/mongodb_oidc.ts index a85fc38c14..f3584c4893 100644 --- a/src/cmap/auth/mongodb_oidc.ts +++ b/src/cmap/auth/mongodb_oidc.ts @@ -6,6 +6,7 @@ import type { Connection } from '../connection'; import { type AuthContext, AuthProvider } from './auth_provider'; import type { MongoCredentials } from './mongo_credentials'; import { AwsServiceWorkflow } from './mongodb_oidc/aws_service_workflow'; +import { AzureServiceWorkflow } from './mongodb_oidc/azure_service_workflow'; import { CallbackWorkflow } from './mongodb_oidc/callback_workflow'; /** Error when credentials are missing. */ @@ -60,7 +61,7 @@ export type OIDCRefreshFunction = ( context: OIDCCallbackContext ) => Promise; -type ProviderName = 'aws' | 'callback'; +type ProviderName = 'aws' | 'azure' | 'callback'; export interface Workflow { /** @@ -84,6 +85,7 @@ export interface Workflow { export const OIDC_WORKFLOWS: Map = new Map(); OIDC_WORKFLOWS.set('callback', new CallbackWorkflow()); OIDC_WORKFLOWS.set('aws', new AwsServiceWorkflow()); +OIDC_WORKFLOWS.set('azure', new AzureServiceWorkflow()); /** * OIDC auth provider. diff --git a/src/cmap/auth/mongodb_oidc/azure_service_workflow.ts b/src/cmap/auth/mongodb_oidc/azure_service_workflow.ts new file mode 100644 index 0000000000..fadbf5e9fd --- /dev/null +++ b/src/cmap/auth/mongodb_oidc/azure_service_workflow.ts @@ -0,0 +1,86 @@ +import { MongoAzureError } from '../../../error'; +import { request } from '../../../utils'; +import type { MongoCredentials } from '../mongo_credentials'; +import { AzureTokenCache } from './azure_token_cache'; +import { ServiceWorkflow } from './service_workflow'; + +/** Base URL for getting Azure tokens. */ +const AZURE_BASE_URL = + 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01'; + +/** Azure request headers. */ +const AZURE_HEADERS = Object.freeze({ Metadata: 'true', Accept: 'application/json' }); + +/** Invalid endpoint result error. */ +const ENDPOINT_RESULT_ERROR = + 'Azure endpoint did not return a value with only access_token and expires_in properties'; + +/** Error for when the token audience is missing in the environment. */ +const TOKEN_AUDIENCE_MISSING_ERROR = + 'TOKEN_AUDIENCE must be set in the auth mechanism properties when PROVIDER_NAME is azure.'; + +/** + * The Azure access token format. + * @internal + */ +export interface AzureAccessToken { + access_token: string; + expires_in: number; +} + +/** + * Device workflow implementation for Azure. + * + * @internal + */ +export class AzureServiceWorkflow extends ServiceWorkflow { + cache = new AzureTokenCache(); + + /** + * Get the token from the environment. + */ + async getToken(credentials?: MongoCredentials): Promise { + const tokenAudience = credentials?.mechanismProperties.TOKEN_AUDIENCE; + if (!tokenAudience) { + throw new MongoAzureError(TOKEN_AUDIENCE_MISSING_ERROR); + } + let token; + const entry = this.cache.getEntry(tokenAudience); + if (entry?.isValid()) { + token = entry.token; + } else { + this.cache.deleteEntry(tokenAudience); + const response = await getAzureTokenData(tokenAudience); + if (!isEndpointResultValid(response)) { + throw new MongoAzureError(ENDPOINT_RESULT_ERROR); + } + this.cache.addEntry(tokenAudience, response); + token = response.access_token; + } + return token; + } +} + +/** + * Hit the Azure endpoint to get the token data. + */ +async function getAzureTokenData(tokenAudience: string): Promise { + const url = `${AZURE_BASE_URL}&resource=${tokenAudience}`; + const data = await request(url, { + json: true, + headers: AZURE_HEADERS + }); + return data as AzureAccessToken; +} + +/** + * Determines if a result returned from the endpoint is valid. + * This means the result is not nullish, contains the access_token required field + * and the expires_in required field. + */ +function isEndpointResultValid( + token: unknown +): token is { access_token: unknown; expires_in: unknown } { + if (token == null || typeof token !== 'object') return false; + return 'access_token' in token && 'expires_in' in token; +} diff --git a/src/cmap/auth/mongodb_oidc/azure_token_cache.ts b/src/cmap/auth/mongodb_oidc/azure_token_cache.ts new file mode 100644 index 0000000000..f68725120e --- /dev/null +++ b/src/cmap/auth/mongodb_oidc/azure_token_cache.ts @@ -0,0 +1,51 @@ +import type { AzureAccessToken } from './azure_service_workflow'; +import { Cache, ExpiringCacheEntry } from './cache'; + +/** @internal */ +export class AzureTokenEntry extends ExpiringCacheEntry { + token: string; + + /** + * Instantiate the entry. + */ + constructor(token: string, expiration: number) { + super(expiration); + this.token = token; + } +} + +/** + * A cache of access tokens from Azure. + * @internal + */ +export class AzureTokenCache extends Cache { + /** + * Add an entry to the cache. + */ + addEntry(tokenAudience: string, token: AzureAccessToken): AzureTokenEntry { + const entry = new AzureTokenEntry(token.access_token, token.expires_in); + this.entries.set(tokenAudience, entry); + return entry; + } + + /** + * Create a cache key. + */ + cacheKey(tokenAudience: string): string { + return tokenAudience; + } + + /** + * Delete an entry from the cache. + */ + deleteEntry(tokenAudience: string): void { + this.entries.delete(tokenAudience); + } + + /** + * Get an Azure token entry from the cache. + */ + getEntry(tokenAudience: string): AzureTokenEntry | undefined { + return this.entries.get(tokenAudience); + } +} diff --git a/src/cmap/auth/mongodb_oidc/cache.ts b/src/cmap/auth/mongodb_oidc/cache.ts index 4a0a825bd4..e23685b3bc 100644 --- a/src/cmap/auth/mongodb_oidc/cache.ts +++ b/src/cmap/auth/mongodb_oidc/cache.ts @@ -1,3 +1,34 @@ +/* 5 minutes in milliseconds */ +const EXPIRATION_BUFFER_MS = 300000; + +/** + * An entry in a cache that can expire in a certain amount of time. + */ +export abstract class ExpiringCacheEntry { + expiration: number; + + /** + * Create a new expiring token entry. + */ + constructor(expiration: number) { + this.expiration = this.expirationTime(expiration); + } + /** + * The entry is still valid if the expiration is more than + * 5 minutes from the expiration time. + */ + isValid() { + return this.expiration - Date.now() > EXPIRATION_BUFFER_MS; + } + + /** + * Get an expiration time in milliseconds past epoch. + */ + private expirationTime(expiresInSeconds: number): number { + return Date.now() + expiresInSeconds * 1000; + } +} + /** * Base class for OIDC caches. */ @@ -18,10 +49,15 @@ export abstract class Cache { this.entries.clear(); } + /** + * Implement the cache key for the token. + */ + abstract cacheKey(address: string, username: string, callbackHash: string): string; + /** * Create a cache key from the address and username. */ - cacheKey(address: string, username: string, callbackHash: string): string { + hashedCacheKey(address: string, username: string, callbackHash: string): string { return JSON.stringify([address, username, callbackHash]); } } diff --git a/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts b/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts index 9e77b0614c..b92a504b0a 100644 --- a/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts +++ b/src/cmap/auth/mongodb_oidc/callback_lock_cache.ts @@ -39,7 +39,7 @@ export class CallbackLockCache extends Cache { * Get the callbacks for the connection and credentials. If an entry does not * exist a new one will get set. */ - getCallbacks(connection: Connection, credentials: MongoCredentials): CallbacksEntry { + getEntry(connection: Connection, credentials: MongoCredentials): CallbacksEntry { const requestCallback = credentials.mechanismProperties.REQUEST_TOKEN_CALLBACK; const refreshCallback = credentials.mechanismProperties.REFRESH_TOKEN_CALLBACK; if (!requestCallback) { @@ -51,13 +51,13 @@ export class CallbackLockCache extends Cache { if (entry) { return entry; } - return this.setCallbacks(key, callbackHash, requestCallback, refreshCallback); + return this.addEntry(key, callbackHash, requestCallback, refreshCallback); } /** * Set locked callbacks on for connection and credentials. */ - private setCallbacks( + private addEntry( key: string, callbackHash: string, requestCallback: OIDCRequestFunction, @@ -71,6 +71,13 @@ export class CallbackLockCache extends Cache { this.entries.set(key, entry); return entry; } + + /** + * Create a cache key from the address and username. + */ + cacheKey(address: string, username: string, callbackHash: string): string { + return this.hashedCacheKey(address, username, callbackHash); + } } /** diff --git a/src/cmap/auth/mongodb_oidc/callback_workflow.ts b/src/cmap/auth/mongodb_oidc/callback_workflow.ts index 3ef1251fc4..c220ae5b70 100644 --- a/src/cmap/auth/mongodb_oidc/callback_workflow.ts +++ b/src/cmap/auth/mongodb_oidc/callback_workflow.ts @@ -65,7 +65,7 @@ export class CallbackWorkflow implements Workflow { response?: Document ): Promise { // Get the callbacks with locks from the callback lock cache. - const { requestCallback, refreshCallback, callbackHash } = this.callbackCache.getCallbacks( + const { requestCallback, refreshCallback, callbackHash } = this.callbackCache.getEntry( connection, credentials ); diff --git a/src/cmap/auth/mongodb_oidc/service_workflow.ts b/src/cmap/auth/mongodb_oidc/service_workflow.ts index 4c3e5bb316..fb01e2c24c 100644 --- a/src/cmap/auth/mongodb_oidc/service_workflow.ts +++ b/src/cmap/auth/mongodb_oidc/service_workflow.ts @@ -16,7 +16,7 @@ export abstract class ServiceWorkflow implements Workflow { * and then attempts to read the token from that path. */ async execute(connection: Connection, credentials: MongoCredentials): Promise { - const token = await this.getToken(); + const token = await this.getToken(credentials); const command = commandDocument(token); return connection.commandAsync(ns(credentials.source), command, undefined); } @@ -25,7 +25,7 @@ export abstract class ServiceWorkflow implements Workflow { * Get the document to add for speculative authentication. */ async speculativeAuth(credentials: MongoCredentials): Promise { - const token = await this.getToken(); + const token = await this.getToken(credentials); const document = commandDocument(token); document.db = credentials.source; return { speculativeAuthenticate: document }; @@ -34,7 +34,7 @@ export abstract class ServiceWorkflow implements Workflow { /** * Get the token from the environment or endpoint. */ - abstract getToken(): Promise; + abstract getToken(credentials: MongoCredentials): Promise; } /** diff --git a/src/cmap/auth/mongodb_oidc/token_entry_cache.ts b/src/cmap/auth/mongodb_oidc/token_entry_cache.ts index 0c24838a5d..1b5b9de331 100644 --- a/src/cmap/auth/mongodb_oidc/token_entry_cache.ts +++ b/src/cmap/auth/mongodb_oidc/token_entry_cache.ts @@ -1,31 +1,21 @@ import type { IdPServerInfo, IdPServerResponse } from '../mongodb_oidc'; -import { Cache } from './cache'; +import { Cache, ExpiringCacheEntry } from './cache'; -/* 5 minutes in milliseconds */ -const EXPIRATION_BUFFER_MS = 300000; /* Default expiration is now for when no expiration provided */ const DEFAULT_EXPIRATION_SECS = 0; + /** @internal */ -export class TokenEntry { +export class TokenEntry extends ExpiringCacheEntry { tokenResult: IdPServerResponse; serverInfo: IdPServerInfo; - expiration: number; /** * Instantiate the entry. */ constructor(tokenResult: IdPServerResponse, serverInfo: IdPServerInfo, expiration: number) { + super(expiration); this.tokenResult = tokenResult; this.serverInfo = serverInfo; - this.expiration = expiration; - } - - /** - * The entry is still valid if the expiration is more than - * 5 minutes from the expiration time. - */ - isValid() { - return this.expiration - Date.now() > EXPIRATION_BUFFER_MS; } } @@ -47,7 +37,7 @@ export class TokenEntryCache extends Cache { const entry = new TokenEntry( tokenResult, serverInfo, - expirationTime(tokenResult.expiresInSeconds) + tokenResult.expiresInSeconds ?? DEFAULT_EXPIRATION_SECS ); this.entries.set(this.cacheKey(address, username, callbackHash), entry); return entry; @@ -77,11 +67,11 @@ export class TokenEntryCache extends Cache { } } } -} -/** - * Get an expiration time in milliseconds past epoch. Defaults to immediate. - */ -function expirationTime(expiresInSeconds?: number): number { - return Date.now() + (expiresInSeconds ?? DEFAULT_EXPIRATION_SECS) * 1000; + /** + * Create a cache key from the address and username. + */ + cacheKey(address: string, username: string, callbackHash: string): string { + return this.hashedCacheKey(address, username, callbackHash); + } } diff --git a/src/connection_string.ts b/src/connection_string.ts index d0f7c2ad7e..789d8342e7 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -207,7 +207,7 @@ function getUIntFromOptions(name: string, value: unknown): number { function* entriesFromString(value: string): Generator<[string, string]> { const keyValuePairs = value.split(','); for (const keyValue of keyValuePairs) { - const [key, value] = keyValue.split(':'); + const [key, value] = keyValue.split(/:(.*)/); if (value == null) { throw new MongoParseError('Cannot have undefined values in key value pairs'); } diff --git a/src/error.ts b/src/error.ts index 3357adfa77..f839cda2df 100644 --- a/src/error.ts +++ b/src/error.ts @@ -390,6 +390,23 @@ export class MongoAWSError extends MongoRuntimeError { } } +/** + * A error generated when the user attempts to authenticate + * via Azure, but fails. + * + * @public + * @category Error + */ +export class MongoAzureError extends MongoRuntimeError { + constructor(message: string) { + super(message); + } + + override get name(): string { + return 'MongoAzureError'; + } +} + /** * An error generated when a ChangeStream operation fails to execute. * diff --git a/src/index.ts b/src/index.ts index 12346002ed..881029d524 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export { ChangeStreamCursor } from './cursor/change_stream_cursor'; export { MongoAPIError, MongoAWSError, + MongoAzureError, MongoBatchReExecutionError, MongoChangeStreamError, MongoCompatibilityError, diff --git a/src/utils.ts b/src/utils.ts index 9c20cb4b53..505f3bfd1d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import * as crypto from 'crypto'; import type { SrvRecord } from 'dns'; +import * as http from 'http'; +import * as url from 'url'; import { URL } from 'url'; import { type Document, ObjectId, resolveBSONOptions } from './bson'; @@ -14,6 +16,7 @@ import { type AnyError, MongoCompatibilityError, MongoInvalidArgumentError, + MongoNetworkTimeoutError, MongoNotConnectedError, MongoParseError, MongoRuntimeError @@ -1266,3 +1269,68 @@ export function matchesParentDomain(address: string, srvHost: string): boolean { return addressDomain.endsWith(srvHostDomain); } + +interface RequestOptions { + json?: boolean; + method?: string; + timeout?: number; + headers?: http.OutgoingHttpHeaders; +} + +export async function request(uri: string): Promise>; +export async function request( + uri: string, + options?: { json?: true } & RequestOptions +): Promise>; +export async function request( + uri: string, + options?: { json: false } & RequestOptions +): Promise; +export async function request( + uri: string, + options: RequestOptions = {} +): Promise> { + return new Promise>((resolve, reject) => { + const requestOptions = { + method: 'GET', + timeout: 10000, + json: true, + ...url.parse(uri), + ...options + }; + + const req = http.request(requestOptions, res => { + res.setEncoding('utf8'); + + let data = ''; + res.on('data', d => { + data += d; + }); + + res.once('end', () => { + if (options.json === false) { + resolve(data); + return; + } + + try { + const parsed = JSON.parse(data); + resolve(parsed); + } catch { + // TODO(NODE-3483) + reject(new MongoRuntimeError(`Invalid JSON response: "${data}"`)); + } + }); + }); + + req.once('timeout', () => + req.destroy( + new MongoNetworkTimeoutError( + `Network request to ${uri} timed out after ${options.timeout} ms` + ) + ) + ); + req.once('error', error => reject(error)); + req.end(); + }); +} diff --git a/test/integration/auth/mongodb_oidc_azure.prose.test.ts b/test/integration/auth/mongodb_oidc_azure.prose.test.ts new file mode 100644 index 0000000000..2dc95b4c93 --- /dev/null +++ b/test/integration/auth/mongodb_oidc_azure.prose.test.ts @@ -0,0 +1,209 @@ +import { expect } from 'chai'; + +import { + type Collection, + type CommandFailedEvent, + type CommandStartedEvent, + type CommandSucceededEvent, + type MongoClient, + OIDC_WORKFLOWS +} from '../../mongodb'; + +describe('OIDC Auth Spec Prose Tests', function () { + const callbackCache = OIDC_WORKFLOWS.get('callback').cache; + const azureCache = OIDC_WORKFLOWS.get('azure').cache; + + describe('3. Azure Automatic Auth', function () { + let client: MongoClient; + let collection: Collection; + + beforeEach(function () { + if (!this.configuration.isAzureOIDC(process.env.MONGODB_URI)) { + this.skipReason = 'Azure OIDC prose tests require an Azure OIDC environment.'; + this.skip(); + } + }); + + afterEach(async function () { + await client?.close(); + }); + + describe('3.1 Connect', function () { + beforeEach(function () { + client = this.configuration.newClient(process.env.MONGODB_URI); + collection = client.db('test').collection('test'); + }); + + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:azure,TOKEN_AUDIENCE:. + // Assert that a find operation succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + }); + }); + + describe('3.2 Allowed Hosts Ignored', function () { + beforeEach(function () { + client = this.configuration.newClient(process.env.MONGODB_URI, { + authMechanismProperties: { + ALLOWED_HOSTS: [] + } + }); + collection = client.db('test').collection('test'); + }); + + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:azure,TOKEN_AUDIENCE:, + // and an ALLOWED_HOSTS that is an empty list. + // Assert that a find operation succeeds. + // Close the client. + it('successfully authenticates', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + }); + }); + + describe('3.3 Main Cache Not Used', function () { + beforeEach(function () { + callbackCache?.clear(); + client = this.configuration.newClient(process.env.MONGODB_URI); + collection = client.db('test').collection('test'); + }); + + // Clear the main OIDC cache. + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:azure,TOKEN_AUDIENCE:. + // Assert that a find operation succeeds. + // Close the client. + // Assert that the main OIDC cache is empty. + it('does not use the main callback cache', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + expect(callbackCache.entries).to.be.empty; + }); + }); + + describe('3.4 Azure Cache is Used', function () { + beforeEach(function () { + callbackCache?.clear(); + azureCache?.clear(); + client = this.configuration.newClient(process.env.MONGODB_URI); + collection = client.db('test').collection('test'); + }); + + // Clear the Azure OIDC cache. + // Create a client with a url of the form mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:azure,TOKEN_AUDIENCE:. + // Assert that a find operation succeeds. + // Close the client. + // Assert that the Azure OIDC cache has one entry. + it('uses the Azure OIDC cache', async function () { + const result = await collection.findOne(); + expect(result).to.be.null; + expect(callbackCache.entries).to.be.empty; + expect(azureCache.entries.size).to.equal(1); + }); + }); + + describe('3.5 Reauthentication Succeeds', function () { + const commandStartedEvents: CommandStartedEvent[] = []; + const commandSucceededEvents: CommandSucceededEvent[] = []; + const commandFailedEvents: CommandFailedEvent[] = []; + + const commandStartedListener = event => { + if (event.commandName === 'find') { + commandStartedEvents.push(event); + } + }; + const commandSucceededListener = event => { + if (event.commandName === 'find') { + commandSucceededEvents.push(event); + } + }; + const commandFailedListener = event => { + if (event.commandName === 'find') { + commandFailedEvents.push(event); + } + }; + + const addListeners = () => { + client.on('commandStarted', commandStartedListener); + client.on('commandSucceeded', commandSucceededListener); + client.on('commandFailed', commandFailedListener); + }; + + // Sets up the fail point for the find to reauthenticate. + const setupFailPoint = async () => { + return await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['find'], + errorCode: 391 + } + }); + }; + + // Removes the fail point. + const removeFailPoint = async () => { + return await client.db().admin().command({ + configureFailPoint: 'failCommand', + mode: 'off' + }); + }; + + beforeEach(async function () { + azureCache?.clear(); + client = this.configuration.newClient(process.env.MONGODB_URI, { monitorCommands: true }); + await client.db('test').collection('test').findOne(); + addListeners(); + await setupFailPoint(); + }); + + afterEach(async function () { + await removeFailPoint(); + }); + + // Clear the Azure OIDC cache. + // Create a client with an event listener. The following assumes that the driver does not emit saslStart or saslContinue events. If the driver does emit those events, ignore/filter them for the purposes of this test. + // Perform a find operation that succeeds. + // Clear the listener state if possible. + // Force a reauthenication using a failCommand of the form: + // + // { + // "configureFailPoint": "failCommand", + // "mode": { + // "times": 1 + // }, + // "data": { + // "failCommands": [ + // "find" + // ], + // "errorCode": 391 + // } + // } + // + //Note + // + //the driver MUST either use a unique appName or explicitly remove the failCommand after the test to prevent leakage. + // + //Perform another find operation that succeeds. + //Assert that the ordering of list started events is [find], , find. Note that if the listener stat could not be cleared then there will and be extra find command. + //Assert that the list of command succeeded events is [find]. + //Assert that a find operation failed once during the command execution. + //Close the client. + it('successfully reauthenticates', async function () { + await client.db('test').collection('test').findOne(); + expect(commandStartedEvents.map(event => event.commandName)).to.deep.equal([ + 'find', + 'find' + ]); + expect(commandSucceededEvents.map(event => event.commandName)).to.deep.equal(['find']); + expect(commandFailedEvents.map(event => event.commandName)).to.deep.equal(['find']); + }); + }); + }); +}); diff --git a/test/mongodb.ts b/test/mongodb.ts index 85ce656c81..53ea38256c 100644 --- a/test/mongodb.ts +++ b/test/mongodb.ts @@ -107,6 +107,8 @@ export * from '../src/cmap/auth/mongocr'; export * from '../src/cmap/auth/mongodb_aws'; export * from '../src/cmap/auth/mongodb_oidc'; export * from '../src/cmap/auth/mongodb_oidc/aws_service_workflow'; +export * from '../src/cmap/auth/mongodb_oidc/azure_service_workflow'; +export * from '../src/cmap/auth/mongodb_oidc/azure_token_cache'; export * from '../src/cmap/auth/mongodb_oidc/callback_lock_cache'; export * from '../src/cmap/auth/mongodb_oidc/callback_workflow'; export * from '../src/cmap/auth/mongodb_oidc/service_workflow'; diff --git a/test/tools/runner/config.ts b/test/tools/runner/config.ts index c7719bb103..dae6f7a4d8 100644 --- a/test/tools/runner/config.ts +++ b/test/tools/runner/config.ts @@ -153,6 +153,10 @@ export class TestConfiguration { return this.options.replicaSet; } + isAzureOIDC(uri: string): boolean { + return uri.indexOf('MONGODB-OIDC') > -1 && uri.indexOf('PROVIDER_NAME:azure') > -1; + } + newClient(dbOptions?: string | Record, serverOptions?: Record) { serverOptions = Object.assign({}, getEnvironmentalOptions(), serverOptions); diff --git a/test/tools/runner/hooks/configuration.js b/test/tools/runner/hooks/configuration.js index 889870312b..45351dbef1 100644 --- a/test/tools/runner/hooks/configuration.js +++ b/test/tools/runner/hooks/configuration.js @@ -155,6 +155,7 @@ const testConfigBeforeHook = async function () { serverApi: MONGODB_API_VERSION, atlas: process.env.ATLAS_CONNECTIVITY != null, aws: MONGODB_URI.includes('authMechanism=MONGODB-AWS'), + azure: MONGODB_URI.includes('PROVIDER_NAME:azure'), adl: this.configuration.buildInfo.dataLake ? this.configuration.buildInfo.dataLake.version : false, diff --git a/test/unit/cmap/auth/mongodb_oidc/azure_token_cache.test.ts b/test/unit/cmap/auth/mongodb_oidc/azure_token_cache.test.ts new file mode 100644 index 0000000000..ac95eb8a9c --- /dev/null +++ b/test/unit/cmap/auth/mongodb_oidc/azure_token_cache.test.ts @@ -0,0 +1,77 @@ +import { expect } from 'chai'; + +import { AzureTokenCache } from '../../../../mongodb'; + +describe('AzureTokenCache', function () { + const tokenResultWithExpiration = Object.freeze({ + access_token: 'test', + expires_in: 100 + }); + + describe('#addEntry', function () { + context('when expiresInSeconds is provided', function () { + const cache = new AzureTokenCache(); + let entry; + + before(function () { + cache.addEntry('audience', tokenResultWithExpiration); + entry = cache.getEntry('audience'); + }); + + it('adds the token result', function () { + expect(entry.token).to.equal('test'); + }); + + it('creates an expiration', function () { + expect(entry.expiration).to.be.within(Date.now(), Date.now() + 100 * 1000); + }); + }); + }); + + describe('#clear', function () { + const cache = new AzureTokenCache(); + + before(function () { + cache.addEntry('audience', tokenResultWithExpiration); + cache.clear(); + }); + + it('clears the cache', function () { + expect(cache.entries.size).to.equal(0); + }); + }); + + describe('#deleteEntry', function () { + const cache = new AzureTokenCache(); + + before(function () { + cache.addEntry('audience', tokenResultWithExpiration); + cache.deleteEntry('audience'); + }); + + it('deletes the entry', function () { + expect(cache.getEntry('audience')).to.not.exist; + }); + }); + + describe('#getEntry', function () { + const cache = new AzureTokenCache(); + + before(function () { + cache.addEntry('audience1', tokenResultWithExpiration); + cache.addEntry('audience2', tokenResultWithExpiration); + }); + + context('when there is a matching entry', function () { + it('returns the entry', function () { + expect(cache.getEntry('audience1')?.token).to.equal('test'); + }); + }); + + context('when there is no matching entry', function () { + it('returns undefined', function () { + expect(cache.getEntry('audience')).to.equal(undefined); + }); + }); + }); +}); diff --git a/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts b/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts index f7e7908142..d10490fa5b 100644 --- a/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts +++ b/test/unit/cmap/auth/mongodb_oidc/callback_lock_cache.test.ts @@ -25,7 +25,7 @@ describe('CallbackLockCache', function () { it('raises an error', function () { try { - cache.getCallbacks(connection, credentials); + cache.getEntry(connection, credentials); expect.fail('Must raise error when no request callback exists.'); } catch (error) { expect(error).to.be.instanceOf(MongoInvalidArgumentError); @@ -71,7 +71,7 @@ describe('CallbackLockCache', function () { } }); const cache = new CallbackLockCache(); - const { requestCallback, refreshCallback, callbackHash } = cache.getCallbacks( + const { requestCallback, refreshCallback, callbackHash } = cache.getEntry( connection, credentials ); @@ -120,7 +120,7 @@ describe('CallbackLockCache', function () { } }); const cache = new CallbackLockCache(); - const { requestCallback, refreshCallback, callbackHash } = cache.getCallbacks( + const { requestCallback, refreshCallback, callbackHash } = cache.getEntry( connection, credentials ); diff --git a/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts b/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts index bb79fa6530..90f3a94085 100644 --- a/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts +++ b/test/unit/cmap/auth/mongodb_oidc/token_entry_cache.test.ts @@ -48,7 +48,7 @@ describe('TokenEntryCache', function () { }); it('sets an immediate expiration', function () { - expect(entry.expiration).to.be.at.most(Date.now()); + expect(entry?.expiration).to.be.at.most(Date.now()); }); }); @@ -67,7 +67,7 @@ describe('TokenEntryCache', function () { }); it('sets an immediate expiration', function () { - expect(entry.expiration).to.be.at.most(Date.now()); + expect(entry?.expiration).to.be.at.most(Date.now()); }); }); }); diff --git a/test/unit/connection_string.test.ts b/test/unit/connection_string.test.ts index 38e454e708..822e813272 100644 --- a/test/unit/connection_string.test.ts +++ b/test/unit/connection_string.test.ts @@ -276,6 +276,22 @@ describe('Connection String', function () { }); }); }); + + context('when TOKEN_AUDIENCE is in the properties', function () { + context('when it is a uri', function () { + const options = parseOptions( + 'mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:azure,TOKEN_AUDIENCE:api%3A%2F%2Ftest' + ); + + it('parses the uri', function () { + expect(options.credentials.mechanismProperties).to.deep.equal({ + PROVIDER_NAME: 'azure', + TOKEN_AUDIENCE: 'api://test', + ALLOWED_HOSTS: DEFAULT_ALLOWED_HOSTS + }); + }); + }); + }); }); it('should parse `authMechanismProperties`', function () { diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 833082b983..fc5cfec41f 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -65,6 +65,7 @@ const EXPECTED_EXPORTS = [ 'MinKey', 'MongoAPIError', 'MongoAWSError', + 'MongoAzureError', 'MongoBatchReExecutionError', 'MongoBulkWriteError', 'MongoChangeStreamError',