From c154c6b67ba9fd0f925fede1f524aeb5d91cb33b Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 3 Apr 2024 06:17:51 -0500 Subject: [PATCH 1/7] PYTHON-4037 Avoid Appending Write/Read Concern in Atlas Search Index Helper Commands (#1570) --- doc/changelog.rst | 2 + pymongo/collection.py | 9 +- test/index_management/createSearchIndex.json | 72 +---- .../index_management/createSearchIndexes.json | 74 +---- .../searchIndexIgnoresReadWriteConcern.json | 252 ++++++++++++++++++ test/test_index_management.py | 29 ++ 6 files changed, 297 insertions(+), 141 deletions(-) create mode 100644 test/index_management/searchIndexIgnoresReadWriteConcern.json diff --git a/doc/changelog.rst b/doc/changelog.rst index 647a783c4..e4f928921 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -41,6 +41,8 @@ PyMongo 4.7 brings a number of improvements including: :attr:`pymongo.monitoring.ConnectionReadyEvent.duration` properties. - Added the ``type`` and ``kwargs`` arguments to :class:`~pymongo.operations.SearchIndexModel` to enable creating vector search indexes in MongoDB Atlas. +- Fixed a bug where ``read_concern`` and ``write_concern`` were improperly added to + :meth:`~pymongo.collection.Collection.list_search_indexes` queries. Unavoidable breaking changes diff --git a/pymongo/collection.py b/pymongo/collection.py index ceba72aff..da1a79966 100644 --- a/pymongo/collection.py +++ b/pymongo/collection.py @@ -72,6 +72,7 @@ from pymongo.operations import ( _IndexList, _Op, ) +from pymongo.read_concern import DEFAULT_READ_CONCERN, ReadConcern from pymongo.read_preferences import ReadPreference, _ServerMode from pymongo.results import ( BulkWriteResult, @@ -81,7 +82,7 @@ from pymongo.results import ( UpdateResult, ) from pymongo.typings import _CollationIn, _DocumentType, _DocumentTypeArg, _Pipeline -from pymongo.write_concern import WriteConcern, validate_boolean +from pymongo.write_concern import DEFAULT_WRITE_CONCERN, WriteConcern, validate_boolean T = TypeVar("T") @@ -119,7 +120,6 @@ if TYPE_CHECKING: from pymongo.collation import Collation from pymongo.database import Database from pymongo.pool import Connection - from pymongo.read_concern import ReadConcern from pymongo.server import Server @@ -2364,7 +2364,10 @@ class Collection(common.BaseObject, Generic[_DocumentType]): pipeline = [{"$listSearchIndexes": {"name": name}}] coll = self.with_options( - codec_options=DEFAULT_CODEC_OPTIONS, read_preference=ReadPreference.PRIMARY + codec_options=DEFAULT_CODEC_OPTIONS, + read_preference=ReadPreference.PRIMARY, + write_concern=DEFAULT_WRITE_CONCERN, + read_concern=DEFAULT_READ_CONCERN, ) cmd = _CollectionAggregationCommand( coll, diff --git a/test/index_management/createSearchIndex.json b/test/index_management/createSearchIndex.json index 31f4c3fdf..f9c4e44d3 100644 --- a/test/index_management/createSearchIndex.json +++ b/test/index_management/createSearchIndex.json @@ -50,8 +50,7 @@ "mappings": { "dynamic": true } - }, - "type": "search" + } } }, "expectError": { @@ -74,8 +73,7 @@ "mappings": { "dynamic": true } - }, - "type": "search" + } } ], "$db": "database0" @@ -99,8 +97,7 @@ "dynamic": true } }, - "name": "test index", - "type": "search" + "name": "test index" } }, "expectError": { @@ -124,68 +121,7 @@ "dynamic": true } }, - "name": "test index", - "type": "search" - } - ], - "$db": "database0" - } - } - } - ] - } - ] - }, - { - "description": "create a vector search index", - "operations": [ - { - "name": "createSearchIndex", - "object": "collection0", - "arguments": { - "model": { - "definition": { - "fields": [ - { - "type": "vector", - "path": "plot_embedding", - "numDimensions": 1536, - "similarity": "euclidean" - } - ] - }, - "name": "test index", - "type": "vectorSearch" - } - }, - "expectError": { - "isError": true, - "errorContains": "Atlas" - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "createSearchIndexes": "collection0", - "indexes": [ - { - "definition": { - "fields": [ - { - "type": "vector", - "path": "plot_embedding", - "numDimensions": 1536, - "similarity": "euclidean" - } - ] - }, - "name": "test index", - "type": "vectorSearch" + "name": "test index" } ], "$db": "database0" diff --git a/test/index_management/createSearchIndexes.json b/test/index_management/createSearchIndexes.json index be2d02cfc..3cf56ce12 100644 --- a/test/index_management/createSearchIndexes.json +++ b/test/index_management/createSearchIndexes.json @@ -83,8 +83,7 @@ "mappings": { "dynamic": true } - }, - "type": "search" + } } ] }, @@ -108,8 +107,7 @@ "mappings": { "dynamic": true } - }, - "type": "search" + } } ], "$db": "database0" @@ -134,8 +132,7 @@ "dynamic": true } }, - "name": "test index", - "type": "search" + "name": "test index" } ] }, @@ -160,70 +157,7 @@ "dynamic": true } }, - "name": "test index", - "type": "search" - } - ], - "$db": "database0" - } - } - } - ] - } - ] - }, - { - "description": "create a vector search index", - "operations": [ - { - "name": "createSearchIndexes", - "object": "collection0", - "arguments": { - "models": [ - { - "definition": { - "fields": [ - { - "type": "vector", - "path": "plot_embedding", - "numDimensions": 1536, - "similarity": "euclidean" - } - ] - }, - "name": "test index", - "type": "vectorSearch" - } - ] - }, - "expectError": { - "isError": true, - "errorContains": "Atlas" - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "createSearchIndexes": "collection0", - "indexes": [ - { - "definition": { - "fields": [ - { - "type": "vector", - "path": "plot_embedding", - "numDimensions": 1536, - "similarity": "euclidean" - } - ] - }, - "name": "test index", - "type": "vectorSearch" + "name": "test index" } ], "$db": "database0" diff --git a/test/index_management/searchIndexIgnoresReadWriteConcern.json b/test/index_management/searchIndexIgnoresReadWriteConcern.json new file mode 100644 index 000000000..edf71b7b7 --- /dev/null +++ b/test/index_management/searchIndexIgnoresReadWriteConcern.json @@ -0,0 +1,252 @@ +{ + "description": "search index operations ignore read and write concern", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "uriOptions": { + "readConcernLevel": "local", + "w": 1 + }, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "createSearchIndex ignores read and write concern", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + } + } + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "createSearchIndexes ignores read and write concern", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [] + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [], + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "dropSearchIndex ignores read and write concern", + "operations": [ + { + "name": "dropSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "dropSearchIndex": "collection0", + "name": "test index", + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "listSearchIndexes ignores read and write concern", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": {} + } + ], + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + }, + { + "description": "updateSearchIndex ignores the read and write concern", + "operations": [ + { + "name": "updateSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index", + "definition": {} + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "updateSearchIndex": "collection0", + "name": "test index", + "definition": {}, + "$db": "database0", + "writeConcern": { + "$$exists": false + }, + "readConcern": { + "$$exists": false + } + } + } + } + ] + } + ] + } + ] +} diff --git a/test/test_index_management.py b/test/test_index_management.py index c9a69aecc..c47cffa75 100644 --- a/test/test_index_management.py +++ b/test/test_index_management.py @@ -30,6 +30,8 @@ from test.utils import AllowListEventListener, EventListener from pymongo import MongoClient from pymongo.errors import OperationFailure from pymongo.operations import SearchIndexModel +from pymongo.read_concern import ReadConcern +from pymongo.write_concern import WriteConcern _TEST_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "index_management") @@ -257,6 +259,33 @@ class TestSearchIndexProse(SearchIndexIntegrationBase): # Run a ``dropSearchIndex`` command and assert that no error is thrown. coll0.drop_search_index("foo") + def test_case_6(self): + """Driver can successfully create and list search indexes with non-default readConcern and writeConcern.""" + # Create a collection with the "create" command using a randomly generated name (referred to as ``coll0``). + coll0 = self.db[f"col{uuid.uuid4()}"] + coll0.insert_one({}) + + # Apply a write concern ``WriteConcern(w=1)`` and a read concern with ``ReadConcern(level="majority")`` to ``coll0``. + coll0 = coll0.with_options( + write_concern=WriteConcern(w="1"), read_concern=ReadConcern(level="majority") + ) + + # Create a new search index on ``coll0`` with the ``createSearchIndex`` helper. + name = "test-search-index-case6" + model = {"name": name, "definition": {"mappings": {"dynamic": False}}} + resp = coll0.create_search_index(model) + + # Assert that the command returns the name of the index: ``"test-search-index-case6"``. + self.assertEqual(resp, name) + + # Run ``coll0.listSearchIndexes()`` repeatedly every 5 seconds until the following condition is satisfied and store the value in a variable ``index``: + # - An index with the ``name`` of ``test-search-index-case6`` is present and the index has a field ``queryable`` with a value of ``true``. + index = self.wait_for_ready(coll0, name) + + # Assert that ``index`` has a property ``latestDefinition`` whose value is ``{ 'mappings': { 'dynamic': false } }`` + self.assertIn("latestDefinition", index) + self.assertEqual(index["latestDefinition"], model["definition"]) + def test_case_7(self): """Driver handles index types.""" From 1e0ef67ab8ae84d9cf32497154f03fb099124a40 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 3 Apr 2024 16:07:41 -0500 Subject: [PATCH 2/7] PYTHON-3664 OIDC: Automatic token acquisition for GCP Identity Provider (#1540) --- .evergreen/config.yml | 56 +++++++++++++++++++++++-- .evergreen/run-mongodb-oidc-test.sh | 5 ++- pymongo/_gcp_helpers.py | 39 +++++++++++++++++ pymongo/auth.py | 8 ++++ pymongo/auth_oidc.py | 10 +++++ test/auth/legacy/connection-string.json | 29 ++++++++++++- test/auth_oidc/test_auth_oidc.py | 12 ++++-- test/unified_format.py | 5 +++ 8 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 pymongo/_gcp_helpers.py diff --git a/.evergreen/config.yml b/.evergreen/config.yml index 5f3515325..a84b84214 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -991,6 +991,30 @@ task_groups: tasks: - oidc-auth-test-azure-latest + - name: testgcpoidc_task_group + setup_group: + - func: fetch source + - func: prepare resources + - func: fix absolute paths + - func: make files executable + - command: subprocess.exec + params: + binary: bash + env: + GCPOIDC_VMNAME_PREFIX: "PYTHON_DRIVER" + args: + - ${DRIVERS_TOOLS}/.evergreen/auth_oidc/gcp/setup.sh + teardown_task: + - command: subprocess.exec + params: + binary: bash + args: + - ${DRIVERS_TOOLS}/.evergreen/auth_oidc/gcp/teardown.sh + setup_group_can_fail_task: true + setup_group_timeout_secs: 1800 + tasks: + - oidc-auth-test-gcp-latest + - name: testoidc_task_group setup_group: - func: fetch source @@ -1966,6 +1990,25 @@ tasks: export AZUREOIDC_TEST_CMD="OIDC_ENV=azure ./.evergreen/run-mongodb-oidc-test.sh" bash $DRIVERS_TOOLS/.evergreen/auth_oidc/azure/run-driver-test.sh + - name: "oidc-auth-test-gcp-latest" + commands: + - command: shell.exec + params: + shell: bash + script: |- + set -o errexit + ${PREPARE_SHELL} + cd src + git add . + git commit -m "add files" + export GCPOIDC_DRIVERS_TAR_FILE=/tmp/mongo-python-driver.tgz + git archive -o $GCPOIDC_DRIVERS_TAR_FILE HEAD + # Define the command to run on the VM. + # Ensure that we source the environment file created for us, set up any other variables we need, + # and then run our test suite on the vm. + export GCPOIDC_TEST_CMD="OIDC_ENV=gcp ./.evergreen/run-mongodb-oidc-test.sh" + bash $DRIVERS_TOOLS/.evergreen/auth_oidc/gcp/run-driver-test.sh + - name: "test-fips-standalone" tags: ["fips"] commands: @@ -2995,18 +3038,25 @@ buildvariants: - matrix_name: "oidc-auth-test" matrix_spec: platform: [ rhel8, macos-1100, windows-64-vsMulti-small ] - display_name: "MONGODB-OIDC Auth ${platform}" + display_name: "OIDC Auth ${platform}" tasks: - name: testoidc_task_group batchtime: 20160 # 14 days - name: testazureoidc-variant - display_name: "Azure OIDC" - run_on: ubuntu2004-small + display_name: "OIDC Auth Azure" + run_on: ubuntu2204-small tasks: - name: testazureoidc_task_group batchtime: 20160 # Use a batchtime of 14 days as suggested by the CSFLE test README +- name: testgcpoidc-variant + display_name: "OIDC Auth GCP" + run_on: ubuntu2204-small + tasks: + - name: testgcpoidc_task_group + batchtime: 20160 # Use a batchtime of 14 days as suggested by the CSFLE test README + - matrix_name: "aws-auth-test" matrix_spec: platform: [ubuntu-20.04] diff --git a/.evergreen/run-mongodb-oidc-test.sh b/.evergreen/run-mongodb-oidc-test.sh index 3c045bf7d..89a211930 100755 --- a/.evergreen/run-mongodb-oidc-test.sh +++ b/.evergreen/run-mongodb-oidc-test.sh @@ -1,7 +1,7 @@ #!/bin/bash set +x # Disable debug trace -set -o errexit # Exit the script with error if any of the commands fail +set -eu echo "Running MONGODB-OIDC authentication tests" @@ -18,6 +18,9 @@ if [ $OIDC_ENV == "test" ]; then elif [ $OIDC_ENV == "azure" ]; then source ./env.sh +elif [ $OIDC_ENV == "gcp" ]; then + source ./secrets-export.sh + else echo "Unrecognized OIDC_ENV $OIDC_ENV" exit 1 diff --git a/pymongo/_gcp_helpers.py b/pymongo/_gcp_helpers.py new file mode 100644 index 000000000..67b177dbf --- /dev/null +++ b/pymongo/_gcp_helpers.py @@ -0,0 +1,39 @@ +# Copyright 2024-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""GCP helpers.""" +from __future__ import annotations + +from typing import Any +from urllib.request import Request, urlopen + + +def _get_gcp_response(resource: str, timeout: float = 5) -> dict[str, Any]: + url = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" + url += f"?audience={resource}" + headers = {"Metadata-Flavor": "Google", "Accept": "application/json"} + request = Request(url, headers=headers) # noqa: S310 + try: + with urlopen(request, timeout=timeout) as response: # noqa: S310 + status = response.status + body = response.read().decode("utf8") + except Exception as e: + msg = "Failed to acquire IMDS access token: %s" % e + raise ValueError(msg) from None + + if status != 200: + msg = "Failed to acquire IMDS access token." + raise ValueError(msg) + + return dict(access_token=body) diff --git a/pymongo/auth.py b/pymongo/auth.py index 542e2676b..263737d94 100644 --- a/pymongo/auth.py +++ b/pymongo/auth.py @@ -41,6 +41,7 @@ from pymongo.auth_oidc import ( _authenticate_oidc, _get_authenticator, _OIDCAzureCallback, + _OIDCGCPCallback, _OIDCProperties, _OIDCTestCallback, ) @@ -207,6 +208,13 @@ def _build_credentials_tuple( "Azure environment for MONGODB-OIDC requires a TOKEN_RESOURCE auth mechanism property" ) callback = _OIDCAzureCallback(token_resource) + elif environ == "gcp": + passwd = None + if not token_resource: + raise ConfigurationError( + "GCP provider for MONGODB-OIDC requires a TOKEN_RESOURCE auth mechanism property" + ) + callback = _OIDCGCPCallback(token_resource) else: raise ConfigurationError(f"unrecognized ENVIRONMENT for MONGODB-OIDC: {environ}") else: diff --git a/pymongo/auth_oidc.py b/pymongo/auth_oidc.py index 939c4fd95..6455cacb1 100644 --- a/pymongo/auth_oidc.py +++ b/pymongo/auth_oidc.py @@ -26,6 +26,7 @@ import bson from bson.binary import Binary from pymongo._azure_helpers import _get_azure_response from pymongo._csot import remaining +from pymongo._gcp_helpers import _get_gcp_response from pymongo.errors import ConfigurationError, OperationFailure if TYPE_CHECKING: @@ -133,6 +134,15 @@ class _OIDCAzureCallback(OIDCCallback): ) +class _OIDCGCPCallback(OIDCCallback): + def __init__(self, token_resource: str) -> None: + self.token_resource = token_resource + + def fetch(self, context: OIDCCallbackContext) -> OIDCCallbackResult: + resp = _get_gcp_response(self.token_resource, context.timeout_seconds) + return OIDCCallbackResult(access_token=resp["access_token"]) + + @dataclass class _OIDCAuthenticator: username: str diff --git a/test/auth/legacy/connection-string.json b/test/auth/legacy/connection-string.json index 2813e4d1c..50afe7cb5 100644 --- a/test/auth/legacy/connection-string.json +++ b/test/auth/legacy/connection-string.json @@ -540,10 +540,37 @@ "credential": null }, { - "description": "should throw and exception if no token audience is given for azure provider (MONGODB-OIDC)", + "description": "should throw an exception if no token audience is given for azure provider (MONGODB-OIDC)", "uri": "mongodb://username@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:azure", "valid": false, "credential": null + }, + { + "description": "should recognise the mechanism with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:foo", + "valid": true, + "credential": { + "username": null, + "password": null, + "source": "$external", + "mechanism": "MONGODB-OIDC", + "mechanism_properties": { + "ENVIRONMENT": "gcp", + "TOKEN_RESOURCE": "foo" + } + } + }, + { + "description": "should throw an error for a username and password with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://user:pass@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:foo", + "valid": false, + "credential": null + }, + { + "description": "should throw an error if not TOKEN_RESOURCE with gcp provider (MONGODB-OIDC)", + "uri": "mongodb://user:pass@localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp", + "valid": false, + "credential": null } ] } diff --git a/test/auth_oidc/test_auth_oidc.py b/test/auth_oidc/test_auth_oidc.py index 0fdfc38eb..9105412fc 100644 --- a/test/auth_oidc/test_auth_oidc.py +++ b/test/auth_oidc/test_auth_oidc.py @@ -27,12 +27,14 @@ from typing import Dict sys.path[0:0] = [""] +import pprint from test.unified_format import generate_test_classes from test.utils import EventListener from bson import SON from pymongo import MongoClient from pymongo._azure_helpers import _get_azure_response +from pymongo._gcp_helpers import _get_gcp_response from pymongo.auth_oidc import ( OIDCCallback, OIDCCallbackResult, @@ -75,10 +77,12 @@ class OIDCTestBase(unittest.TestCase): return fid.read() elif ENVIRON == "azure": opts = parse_uri(self.uri_single)["options"] - resource = opts["authmechanismproperties"]["TOKEN_RESOURCE"] - return _get_azure_response(resource, username)["access_token"] - else: - raise RuntimeError(f"Invalid ENVIRONMENT {ENVIRON}") + token_aud = opts["authmechanismproperties"]["TOKEN_RESOURCE"] + return _get_azure_response(token_aud, username)["access_token"] + elif ENVIRON == "gcp": + opts = parse_uri(self.uri_single)["options"] + token_aud = opts["authmechanismproperties"]["TOKEN_RESOURCE"] + return _get_gcp_response(token_aud, username)["access_token"] @contextmanager def fail_point(self, command_args): diff --git a/test/unified_format.py b/test/unified_format.py index 86056278a..93ef10090 100644 --- a/test/unified_format.py +++ b/test/unified_format.py @@ -172,6 +172,11 @@ elif OIDC_ENV == "azure": "ENVIRONMENT": "azure", "TOKEN_RESOURCE": os.environ["AZUREOIDC_RESOURCE"], } +elif OIDC_ENV == "gcp": + PLACEHOLDER_MAP["/uriOptions/authMechanismProperties"] = { + "ENVIRONMENT": "gcp", + "TOKEN_RESOURCE": os.environ["GCPOIDC_AUDIENCE"], + } def interrupt_loop(): From 167b9648caf94d57ee618ed240d7910f4553b55e Mon Sep 17 00:00:00 2001 From: Alessio Castrica <64859146+Ale-Cas@users.noreply.github.com> Date: Thu, 4 Apr 2024 21:57:07 +0200 Subject: [PATCH 3/7] PYTHON-4298 Raise ConfigurationError not TypeError when round_trip_time is None in server selection (#1566) Co-authored-by: Alessio --- pymongo/topology_description.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pymongo/topology_description.py b/pymongo/topology_description.py index 99243d7ce..cc2330cba 100644 --- a/pymongo/topology_description.py +++ b/pymongo/topology_description.py @@ -265,8 +265,14 @@ class TopologyDescription: def _apply_local_threshold(self, selection: Optional[Selection]) -> list[ServerDescription]: if not selection: return [] + round_trip_times: list[float] = [] + for server in selection.server_descriptions: + if server.round_trip_time is None: + config_err_msg = f"round_trip_time for server {server.address} is unexpectedly None: {self}, servers: {selection.server_descriptions}" + raise ConfigurationError(config_err_msg) + round_trip_times.append(server.round_trip_time) # Round trip time in seconds. - fastest = min(cast(float, s.round_trip_time) for s in selection.server_descriptions) + fastest = min(round_trip_times) threshold = self._topology_settings.local_threshold_ms / 1000.0 return [ s From 2da8af01a6c30ea8ef19a6250a482892b735ac17 Mon Sep 17 00:00:00 2001 From: Noah Stapp Date: Fri, 5 Apr 2024 11:21:18 -0700 Subject: [PATCH 4/7] PYTHON-3416 Mongos SRV Poller should wait 60 seconds to poll (#1571) --- pymongo/monitor.py | 4 ++++ test/test_srv_polling.py | 24 ++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/pymongo/monitor.py b/pymongo/monitor.py index bf8525fe8..64945dd10 100644 --- a/pymongo/monitor.py +++ b/pymongo/monitor.py @@ -335,8 +335,12 @@ class SrvMonitor(MonitorBase): self._seedlist = self._settings._seeds assert isinstance(self._settings.fqdn, str) self._fqdn: str = self._settings.fqdn + self._startup_time = time.monotonic() def _run(self) -> None: + # Don't poll right after creation, wait 60 seconds first + if time.monotonic() < self._startup_time + common.MIN_SRV_RESCAN_INTERVAL: + return seedlist = self._get_seedlist() if seedlist: self._seedlist = seedlist diff --git a/test/test_srv_polling.py b/test/test_srv_polling.py index 549391d1b..636487012 100644 --- a/test/test_srv_polling.py +++ b/test/test_srv_polling.py @@ -111,7 +111,7 @@ class TestSrvPolling(unittest.TestCase): def get_nodelist(self, client): return client._topology.description.server_descriptions().keys() - def assert_nodelist_change(self, expected_nodelist, client): + def assert_nodelist_change(self, expected_nodelist, client, timeout=(100 * WAIT_TIME)): """Check if the client._topology eventually sees all nodes in the expected_nodelist. """ @@ -122,9 +122,9 @@ class TestSrvPolling(unittest.TestCase): return True return False - wait_until(predicate, "see expected nodelist", timeout=100 * WAIT_TIME) + wait_until(predicate, "see expected nodelist", timeout=timeout) - def assert_nodelist_nochange(self, expected_nodelist, client): + def assert_nodelist_nochange(self, expected_nodelist, client, timeout=(100 * WAIT_TIME)): """Check if the client._topology ever deviates from seeing all nodes in the expected_nodelist. Consistency is checked after sleeping for (WAIT_TIME * 10) seconds. Also check that the resolver is called at @@ -136,7 +136,7 @@ class TestSrvPolling(unittest.TestCase): return pymongo.srv_resolver._SrvResolver.get_hosts_and_min_ttl.call_count >= 1 return False - wait_until(predicate, "Node list equals expected nodelist", timeout=100 * WAIT_TIME) + wait_until(predicate, "Node list equals expected nodelist", timeout=timeout) nodelist = self.get_nodelist(client) if set(expected_nodelist) != set(nodelist): @@ -330,6 +330,22 @@ class TestSrvPolling(unittest.TestCase): with SrvPollingKnobs(nodelist_callback=nodelist_callback): self.assert_nodelist_change(response, client) + def test_srv_waits_to_poll(self): + modified = [("localhost.test.build.10gen.cc", 27019)] + + def resolver_response(): + return modified + + with SrvPollingKnobs( + ttl_time=WAIT_TIME, + min_srv_rescan_interval=WAIT_TIME, + nodelist_callback=resolver_response, + ): + client = MongoClient(self.CONNECTION_STRING) + self.assertRaises( + AssertionError, self.assert_nodelist_change, modified, client, timeout=WAIT_TIME / 2 + ) + if __name__ == "__main__": unittest.main() From 48d5a46e46e88e337df8cd8ae0fbb77788746e9d Mon Sep 17 00:00:00 2001 From: Shane Harvey Date: Mon, 8 Apr 2024 11:34:51 -0700 Subject: [PATCH 5/7] PYTHON-4332 create_search_index only sends "type" field when provided (#1573) --- pymongo/operations.py | 5 +++-- test/test_index_management.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pymongo/operations.py b/pymongo/operations.py index 58655753b..4872afa91 100644 --- a/pymongo/operations.py +++ b/pymongo/operations.py @@ -592,7 +592,7 @@ class SearchIndexModel: self, definition: Mapping[str, Any], name: Optional[str] = None, - type: Optional[str] = "search", + type: Optional[str] = None, **kwargs: Any, ) -> None: """Create a Search Index instance. @@ -613,7 +613,8 @@ class SearchIndexModel: if name is not None: self.__document["name"] = name self.__document["definition"] = definition - self.__document["type"] = type + if type is not None: + self.__document["type"] = type self.__document.update(kwargs) @property diff --git a/test/test_index_management.py b/test/test_index_management.py index c47cffa75..5b6653dcb 100644 --- a/test/test_index_management.py +++ b/test/test_index_management.py @@ -62,7 +62,17 @@ class TestCreateSearchIndex(IntegrationTest): listener.reset() with self.assertRaises(OperationFailure): coll.create_search_index({"definition": definition, "arbitraryOption": 1}) - self.assertIn("arbitraryOption", listener.events[0].command["indexes"][0]) + self.assertEqual( + {"definition": definition, "arbitraryOption": 1}, + listener.events[0].command["indexes"][0], + ) + + listener.reset() + with self.assertRaises(OperationFailure): + coll.create_search_index({"definition": definition, "type": "search"}) + self.assertEqual( + {"definition": definition, "type": "search"}, listener.events[0].command["indexes"][0] + ) class SearchIndexIntegrationBase(unittest.TestCase): From 8be31bf8f108072c16f6588fa2540ad9f48326a5 Mon Sep 17 00:00:00 2001 From: Shane Harvey Date: Tue, 9 Apr 2024 18:44:42 -0700 Subject: [PATCH 6/7] PYTHON-4350 Faster and more consistent performance benchmark execution times (#1575) --- test/performance/perf_test.py | 63 ++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/test/performance/perf_test.py b/test/performance/perf_test.py index add3332b8..6646d4eb4 100644 --- a/test/performance/perf_test.py +++ b/test/performance/perf_test.py @@ -1,4 +1,4 @@ -# Copyright 2015 MongoDB, Inc. +# Copyright 2015-present MongoDB, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for the MongoDB Driver Performance Benchmarking Spec.""" +"""Tests for the MongoDB Driver Performance Benchmarking Spec. + +See https://github.com/mongodb/specifications/blob/master/source/benchmarking/benchmarking.md + + +To set up the benchmarks locally:: + + python -m pip install simplejson + git clone --depth 1 https://github.com/mongodb/specifications.git + pushd specifications/source/benchmarking/data + tar xf extended_bson.tgz + tar xf parallel.tgz + tar xf single_and_multi_document.tgz + popd + export TEST_PATH="specifications/source/benchmarking/data" + export OUTPUT_FILE="results.json" + +Then to run all benchmarks quickly:: + + FASTBENCH=1 python test/performance/perf_test.py -v + +To run individual benchmarks quickly:: + + FASTBENCH=1 python test/performance/perf_test.py -v TestRunCommand TestFindManyAndEmptyCursor +""" from __future__ import annotations import multiprocessing as mp @@ -36,9 +60,18 @@ from bson import decode, encode, json_util from gridfs import GridFSBucket from pymongo import MongoClient +# Spec says to use at least 1 minute cumulative execution time and up to 100 iterations or 5 minutes but that +# makes the benchmarks too slow. Instead, we use at least 30 seconds and at most 60 seconds. NUM_ITERATIONS = 100 -MAX_ITERATION_TIME = 300 +MIN_ITERATION_TIME = 30 +MAX_ITERATION_TIME = 60 NUM_DOCS = 10000 +# When debugging or prototyping it's often useful to run the benchmarks locally, set FASTBENCH=1 to run quickly. +if bool(os.getenv("FASTBENCH")): + NUM_ITERATIONS = 2 + MIN_ITERATION_TIME = 0.1 + MAX_ITERATION_TIME = 0.5 + NUM_DOCS = 1000 TEST_PATH = os.environ.get( "TEST_PATH", os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.join("data")) @@ -88,7 +121,7 @@ class PerformanceTest: megabytes_per_sec = self.data_size / median / 1000000 print( f"Completed {self.__class__.__name__} {megabytes_per_sec:.3f} MB/s, MEDIAN={self.percentile(50):.3f}s, " - f"total time={duration:.3f}s" + f"total time={duration:.3f}s, iterations={len(self.results)}" ) result_data.append( { @@ -125,19 +158,25 @@ class PerformanceTest: def runTest(self): results = [] start = time.monotonic() - for i in range(NUM_ITERATIONS): - if time.monotonic() - start > MAX_ITERATION_TIME: - with warnings.catch_warnings(): - warnings.simplefilter("default") - warnings.warn( - f"Test timed out after {MAX_ITERATION_TIME}s, completed {i}/{NUM_ITERATIONS} iterations." - ) - break + i = 0 + while True: + i += 1 self.before() with Timer() as timer: self.do_task() self.after() results.append(timer.interval) + duration = time.monotonic() - start + if duration > MIN_ITERATION_TIME and i >= NUM_ITERATIONS: + break + if duration > MAX_ITERATION_TIME: + with warnings.catch_warnings(): + warnings.simplefilter("default") + warnings.warn( + f"{self.__class__.__name__} timed out after {MAX_ITERATION_TIME}s, completed {i}/{NUM_ITERATIONS} iterations." + ) + + break self.results = results From efe8cc38a6ed9a84536a278e21834432a6590a96 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 10 Apr 2024 11:29:42 -0500 Subject: [PATCH 7/7] PYTHON-4256 OIDC Spec Cleanup (#1556) --- pymongo/auth_oidc.py | 44 +- pymongo/common.py | 1 - pymongo/helpers.py | 3 + test/auth/legacy/connection-string.json | 8 +- test/auth/unified/mongodb-oidc-no-retry.json | 1119 ++++++++--------- .../unified/reauthenticate_with_retry.json | 191 --- .../unified/reauthenticate_without_retry.json | 191 --- test/auth_oidc/test_auth_oidc.py | 150 ++- 8 files changed, 721 insertions(+), 986 deletions(-) delete mode 100644 test/auth/unified/reauthenticate_with_retry.json delete mode 100644 test/auth/unified/reauthenticate_without_retry.json diff --git a/pymongo/auth_oidc.py b/pymongo/auth_oidc.py index 6455cacb1..43b935be7 100644 --- a/pymongo/auth_oidc.py +++ b/pymongo/auth_oidc.py @@ -28,6 +28,7 @@ from pymongo._azure_helpers import _get_azure_response from pymongo._csot import remaining from pymongo._gcp_helpers import _get_gcp_response from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.helpers import _AUTHENTICATION_FAILURE_CODE if TYPE_CHECKING: from pymongo.auth import MongoCredential @@ -37,7 +38,7 @@ if TYPE_CHECKING: @dataclass class OIDCIdPInfo: issuer: str - clientId: str + clientId: Optional[str] = field(default=None) requestScopes: Optional[list[str]] = field(default=None) @@ -189,30 +190,43 @@ class _OIDCAuthenticator: def _authenticate_machine(self, conn: Connection) -> Mapping[str, Any]: # If there is a cached access token, try to authenticate with it. If - # authentication fails, it's possible the cached access token is expired. In - # that case, invalidate the access token, fetch a new access token, and try - # to authenticate again. + # authentication fails with error code 18, invalidate the access token, + # fetch a new access token, and try to authenticate again. If authentication + # fails for any other reason, raise the error to the user. if self.access_token: try: return self._sasl_start_jwt(conn) - except Exception: # noqa: S110 - pass + except OperationFailure as e: + if self._is_auth_error(e): + return self._authenticate_machine(conn) + raise return self._sasl_start_jwt(conn) def _authenticate_human(self, conn: Connection) -> Optional[Mapping[str, Any]]: # If we have a cached access token, try a JwtStepRequest. + # authentication fails with error code 18, invalidate the access token, + # and try to authenticate again. If authentication fails for any other + # reason, raise the error to the user. if self.access_token: try: return self._sasl_start_jwt(conn) - except Exception: # noqa: S110 - pass + except OperationFailure as e: + if self._is_auth_error(e): + return self._authenticate_human(conn) + raise # If we have a cached refresh token, try a JwtStepRequest with that. + # If authentication fails with error code 18, invalidate the access and + # refresh tokens, and try to authenticate again. If authentication fails for + # any other reason, raise the error to the user. if self.refresh_token: try: return self._sasl_start_jwt(conn) - except Exception: # noqa: S110 - pass + except OperationFailure as e: + if self._is_auth_error(e): + self.refresh_token = None + return self._authenticate_human(conn) + raise # Start a new Two-Step SASL conversation. # Run a PrincipalStepRequest to get the IdpInfo. @@ -280,10 +294,16 @@ class _OIDCAuthenticator: def _run_command(self, conn: Connection, cmd: MutableMapping[str, Any]) -> Mapping[str, Any]: try: return conn.command("$external", cmd, no_reauth=True) # type: ignore[call-arg] - except OperationFailure: - self._invalidate(conn) + except OperationFailure as e: + if self._is_auth_error(e): + self._invalidate(conn) raise + def _is_auth_error(self, err: Exception) -> bool: + if not isinstance(err, OperationFailure): + return False + return err.code == _AUTHENTICATION_FAILURE_CODE + def _invalidate(self, conn: Connection) -> None: # Ignore the invalidation if a token gen id is given and is less than our # current token gen id. diff --git a/pymongo/common.py b/pymongo/common.py index 830874bc8..e990fddf8 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -426,7 +426,6 @@ _MECHANISM_PROPS = frozenset( "AWS_SESSION_TOKEN", "ENVIRONMENT", "TOKEN_RESOURCE", - "ALLOWED_HOSTS", ] ) diff --git a/pymongo/helpers.py b/pymongo/helpers.py index e1769e0df..916d78a33 100644 --- a/pymongo/helpers.py +++ b/pymongo/helpers.py @@ -90,6 +90,9 @@ _RETRYABLE_ERROR_CODES: frozenset = _NOT_PRIMARY_CODES | frozenset( # Server code raised when re-authentication is required _REAUTHENTICATION_REQUIRED_CODE: int = 391 +# Server code raised when authentication fails. +_AUTHENTICATION_FAILURE_CODE: int = 18 + def _gen_index_name(keys: _IndexList) -> str: """Generate an index name from the set of fields it is over.""" diff --git a/test/auth/legacy/connection-string.json b/test/auth/legacy/connection-string.json index 50afe7cb5..43b4678c7 100644 --- a/test/auth/legacy/connection-string.json +++ b/test/auth/legacy/connection-string.json @@ -497,6 +497,12 @@ "valid": false, "credential": null }, + { + "description": "should throw an exception custom callback is chosen but no callback is provided (MONGODB-OIDC)", + "uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC&authMechanismProperties=PROVIDER_NAME:custom", + "valid": false, + "credential": null + }, { "description": "should throw an exception if neither provider nor callbacks specified (MONGODB-OIDC)", "uri": "mongodb://localhost/?authMechanism=MONGODB-OIDC", @@ -573,4 +579,4 @@ "credential": null } ] -} +} \ No newline at end of file diff --git a/test/auth/unified/mongodb-oidc-no-retry.json b/test/auth/unified/mongodb-oidc-no-retry.json index 116ca3eea..613af8734 100644 --- a/test/auth/unified/mongodb-oidc-no-retry.json +++ b/test/auth/unified/mongodb-oidc-no-retry.json @@ -1,601 +1,588 @@ { - "description": "MONGODB-OIDC authentication with retry disabled", - "schemaVersion": "1.19", - "runOnRequirements": [ - { - "minServerVersion": "7.0", - "auth": true, - "authMechanism": "MONGODB-OIDC" + "description": "MONGODB-OIDC authentication with retry disabled", + "schemaVersion": "1.19", + "runOnRequirements": [ + { + "minServerVersion": "7.0", + "auth": true, + "authMechanism": "MONGODB-OIDC" + } + ], + "createEntities": [ + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false } - ], - "createEntities": [ - { - "client": { - "id": "failPointClient", - "useMultipleMongoses": false - } - }, - { - "client": { - "id": "client0", - "uriOptions": { - "authMechanism": "MONGODB-OIDC", - "authMechanismProperties": { - "$$placeholder": 1 - }, - "retryReads": false, - "retryWrites": false + }, + { + "client": { + "id": "client0", + "uriOptions": { + "authMechanism": "MONGODB-OIDC", + "authMechanismProperties": { + "$$placeholder": 1 }, - "observeEvents": [ - "commandStartedEvent", - "commandSucceededEvent", - "commandFailedEvent" + "retryReads": false, + "retryWrites": false + }, + "observeEvents": [ + "commandStartedEvent", + "commandSucceededEvent", + "commandFailedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "test" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collName" + } + } + ], + "initialData": [ + { + "collectionName": "collName", + "databaseName": "test", + "documents": [] + } + ], + "tests": [ + { + "description": "A read operation should succeed", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectResult": [] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collName", + "filter": {} + } + } + }, + { + "commandSucceededEvent": { + "commandName": "find" + } + } ] } - }, - { - "database": { - "id": "database0", + ] + }, + { + "description": "A write operation should succeed", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } + } + } + ], + "expectEvents": [ + { "client": "client0", - "databaseName": "test" + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 + } + ] + } + } + }, + { + "commandSucceededEvent": { + "commandName": "insert" + } + } + ] } - }, - { - "collection": { - "id": "collection0", - "database": "database0", - "collectionName": "collName" + ] + }, + { + "description": "Read commands should reauthenticate and retry when a ReauthenticationRequired error happens", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "errorCode": 391 + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectResult": [] } - } - ], - "initialData": [ - { - "collectionName": "collName", - "databaseName": "test", - "documents": [ - - ] - } - ], - "tests": [ - { - "description": "A read operation should succeed", - "operations": [ - { - "name": "find", - "object": "collection0", - "arguments": { - "filter": { + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collName", + "filter": {} + } } }, - "expectResult": [ - - ] + { + "commandFailedEvent": { + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "collName", + "filter": {} + } + } + }, + { + "commandSucceededEvent": { + "commandName": "find" + } + } + ] + } + ] + }, + { + "description": "Write commands should reauthenticate and retry when a ReauthenticationRequired error happens", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 391 + } + } } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": { + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 } - } + ] } - }, - { - "commandSucceededEvent": { - "commandName": "find" - } - } - ] - } - ] - }, - { - "description": "A write operation should succeed", - "operations": [ - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - } - ] - } - ] - }, - { - "description": "Read commands should reauthenticate and retry when a ReauthenticationRequired error happens", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "find" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "find", - "object": "collection0", - "arguments": { - "filter": { } }, - "expectResult": [ - - ] - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": { + { + "commandFailedEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 } - } + ] } + } + }, + { + "commandSucceededEvent": { + "commandName": "insert" + } + } + ] + } + ] + }, + { + "description": "Handshake with cached token should use speculative authentication", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 }, - { - "commandFailedEvent": { - "commandName": "find" - } + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } + }, + "expectError": { + "isClientError": true + } + }, + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 }, - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": { + "data": { + "failCommands": [ + "saslStart" + ], + "errorCode": 18 + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 } - } + ] } - }, - { - "commandSucceededEvent": { - "commandName": "find" - } - } - ] - } - ] - }, - { - "description": "Write commands should reauthenticate and retry when a ReauthenticationRequired error happens", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "insert" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandFailedEvent": { - "commandName": "insert" - } - }, - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - } - ] - } - ] - }, - { - "description": "Handshake with cached token should use speculative authentication", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "insert" - ], - "closeConnection": true - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 } }, - "expectError": { - "isClientError": true - } - }, - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": "alwaysOn", - "data": { - "failCommands": [ - "saslStart" - ], - "errorCode": 20 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandFailedEvent": { - "commandName": "insert" - } - }, - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - } - ] - } - ] - }, - { - "description": "Handshake without cached token should not use speculative authentication", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": "alwaysOn", - "data": { - "failCommands": [ - "saslStart" - ], - "errorCode": 20 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 + { + "commandFailedEvent": { + "commandName": "insert" } }, - "expectError": { - "errorCode": 20 - } - } - ] - }, - { - "description": "Read commands should fail if reauthentication fails", - "operations": [ - { - "name": "find", - "object": "collection0", - "arguments": { - "filter": { - } - }, - "expectResult": [ - - ] - }, - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 2 - }, - "data": { - "failCommands": [ - "find", - "saslStart" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "find", - "object": "collection0", - "arguments": { - "filter": { - } - }, - "expectError": { - "errorCode": 391 - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": { + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 } - } + ] } - }, - { - "commandSucceededEvent": { - "commandName": "find" - } - }, - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": { - } - } - } - }, - { - "commandFailedEvent": { - "commandName": "find" - } - } - ] - } - ] - }, - { - "description": "Write commands should fail if reauthentication fails", - "operations": [ - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - }, - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "failPointClient", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 2 - }, - "data": { - "failCommands": [ - "insert", - "saslStart" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 2, - "x": 2 } }, - "expectError": { - "errorCode": 391 + { + "commandSucceededEvent": { + "commandName": "insert" + } + } + ] + } + ] + }, + { + "description": "Handshake without cached token should not use speculative authentication", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "saslStart" + ], + "errorCode": 18 + } } } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } + }, + "expectError": { + "errorCode": 18 + } + } + ] + }, + { + "description": "Read commands should fail if reauthentication fails", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectResult": [] + }, + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - }, - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 2, - "x": 2 - } - ] - } - } - }, - { - "commandFailedEvent": { - "commandName": "insert" + "data": { + "failCommands": [ + "find", + "saslStart" + ], + "errorCode": 391 + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectError": { + "errorCode": 391 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "collName", + "filter": {} } } - ] + }, + { + "commandSucceededEvent": { + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "collName", + "filter": {} + } + } + }, + { + "commandFailedEvent": { + "commandName": "find" + } + } + ] + } + ] + }, + { + "description": "Write commands should fail if reauthentication fails", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 1, + "x": 1 + } } - ] - } - ] - } \ No newline at end of file + }, + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "insert", + "saslStart" + ], + "errorCode": 391 + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 2, + "x": 2 + } + }, + "expectError": { + "errorCode": 391 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 1, + "x": 1 + } + ] + } + } + }, + { + "commandSucceededEvent": { + "commandName": "insert" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "collName", + "documents": [ + { + "_id": 2, + "x": 2 + } + ] + } + } + }, + { + "commandFailedEvent": { + "commandName": "insert" + } + } + ] + } + ] + } + ] +} diff --git a/test/auth/unified/reauthenticate_with_retry.json b/test/auth/unified/reauthenticate_with_retry.json deleted file mode 100644 index ef110562e..000000000 --- a/test/auth/unified/reauthenticate_with_retry.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "description": "reauthenticate_with_retry", - "schemaVersion": "1.12", - "runOnRequirements": [ - { - "minServerVersion": "6.3", - "auth": true - } - ], - "createEntities": [ - { - "client": { - "id": "client0", - "uriOptions": { - "retryReads": true, - "retryWrites": true - }, - "observeEvents": [ - "commandStartedEvent", - "commandSucceededEvent", - "commandFailedEvent" - ] - } - }, - { - "database": { - "id": "database0", - "client": "client0", - "databaseName": "db" - } - }, - { - "collection": { - "id": "collection0", - "database": "database0", - "collectionName": "collName" - } - } - ], - "initialData": [ - { - "collectionName": "collName", - "databaseName": "db", - "documents": [] - } - ], - "tests": [ - { - "description": "Read command should reauthenticate when receive ReauthenticationRequired error code and retryReads=true", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "client0", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "find" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "find", - "arguments": { - "filter": {} - }, - "object": "collection0", - "expectResult": [] - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": {} - } - } - }, - { - "commandFailedEvent": { - "commandName": "find" - } - }, - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": {} - } - } - }, - { - "commandSucceededEvent": { - "commandName": "find" - } - } - ] - } - ] - }, - { - "description": "Write command should reauthenticate when receive ReauthenticationRequired error code and retryWrites=true", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "client0", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "insert" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandFailedEvent": { - "commandName": "insert" - } - }, - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - } - ] - } - ] - } - ] -} diff --git a/test/auth/unified/reauthenticate_without_retry.json b/test/auth/unified/reauthenticate_without_retry.json deleted file mode 100644 index 6fded4763..000000000 --- a/test/auth/unified/reauthenticate_without_retry.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "description": "reauthenticate_without_retry", - "schemaVersion": "1.12", - "runOnRequirements": [ - { - "minServerVersion": "6.3", - "auth": true - } - ], - "createEntities": [ - { - "client": { - "id": "client0", - "uriOptions": { - "retryReads": false, - "retryWrites": false - }, - "observeEvents": [ - "commandStartedEvent", - "commandSucceededEvent", - "commandFailedEvent" - ] - } - }, - { - "database": { - "id": "database0", - "client": "client0", - "databaseName": "db" - } - }, - { - "collection": { - "id": "collection0", - "database": "database0", - "collectionName": "collName" - } - } - ], - "initialData": [ - { - "collectionName": "collName", - "databaseName": "db", - "documents": [] - } - ], - "tests": [ - { - "description": "Read command should reauthenticate when receive ReauthenticationRequired error code and retryReads=false", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "client0", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "find" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "find", - "arguments": { - "filter": {} - }, - "object": "collection0", - "expectResult": [] - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": {} - } - } - }, - { - "commandFailedEvent": { - "commandName": "find" - } - }, - { - "commandStartedEvent": { - "command": { - "find": "collName", - "filter": {} - } - } - }, - { - "commandSucceededEvent": { - "commandName": "find" - } - } - ] - } - ] - }, - { - "description": "Write command should reauthenticate when receive ReauthenticationRequired error code and retryWrites=false", - "operations": [ - { - "name": "failPoint", - "object": "testRunner", - "arguments": { - "client": "client0", - "failPoint": { - "configureFailPoint": "failCommand", - "mode": { - "times": 1 - }, - "data": { - "failCommands": [ - "insert" - ], - "errorCode": 391 - } - } - } - }, - { - "name": "insertOne", - "object": "collection0", - "arguments": { - "document": { - "_id": 1, - "x": 1 - } - } - } - ], - "expectEvents": [ - { - "client": "client0", - "events": [ - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandFailedEvent": { - "commandName": "insert" - } - }, - { - "commandStartedEvent": { - "command": { - "insert": "collName", - "documents": [ - { - "_id": 1, - "x": 1 - } - ] - } - } - }, - { - "commandSucceededEvent": { - "commandName": "insert" - } - } - ] - } - ] - } - ] -} diff --git a/test/auth_oidc/test_auth_oidc.py b/test/auth_oidc/test_auth_oidc.py index 9105412fc..bb0a95028 100644 --- a/test/auth_oidc/test_auth_oidc.py +++ b/test/auth_oidc/test_auth_oidc.py @@ -35,10 +35,7 @@ from bson import SON from pymongo import MongoClient from pymongo._azure_helpers import _get_azure_response from pymongo._gcp_helpers import _get_gcp_response -from pymongo.auth_oidc import ( - OIDCCallback, - OIDCCallbackResult, -) +from pymongo.auth_oidc import OIDCCallback, OIDCCallbackContext, OIDCCallbackResult from pymongo.cursor import CursorType from pymongo.errors import AutoReconnect, ConfigurationError, OperationFailure from pymongo.hello import HelloCompat @@ -107,15 +104,24 @@ class TestAuthOIDCHuman(OIDCTestBase): raise ValueError("Missing OIDC_DOMAIN") super().setUpClass() + def setUp(self): + self.refresh_present = 0 + super().setUp() + def create_request_cb(self, username="test_user1", sleep=0): - def request_token(context): + def request_token(context: OIDCCallbackContext): # Validate the info. self.assertIsInstance(context.idp_info.issuer, str) - self.assertIsInstance(context.idp_info.clientId, str) + if context.idp_info.clientId is not None: + self.assertIsInstance(context.idp_info.clientId, str) # Validate the timeout. timeout_seconds = context.timeout_seconds self.assertEqual(timeout_seconds, 60 * 5) + + if context.refresh_token: + self.refresh_present += 1 + token = self.get_token(username) resp = OIDCCallbackResult(access_token=token, refresh_token=token) @@ -131,7 +137,7 @@ class TestAuthOIDCHuman(OIDCTestBase): def create_client(self, *args, **kwargs): username = kwargs.get("username", "test_user1") - if kwargs.get("username"): + if kwargs.get("username") in ["test_user1", "test_user2"]: kwargs["username"] = f"{username}@{DOMAIN}" request_cb = kwargs.pop("request_cb", self.create_request_cb(username=username)) props = kwargs.pop("authmechanismproperties", {"OIDC_HUMAN_CALLBACK": request_cb}) @@ -219,6 +225,26 @@ class TestAuthOIDCHuman(OIDCTestBase): # Close the client. client.close() + def test_1_7_allowed_hosts_in_connection_string_ignored(self): + # Create an OIDC configured client with the connection string: `mongodb+srv://example.com/?authMechanism=MONGODB-OIDC&authMechanismProperties=ALLOWED_HOSTS:%5B%22example.com%22%5D` and a Human Callback. + # Assert that the creation of the client raises a configuration error. + uri = "mongodb+srv://example.com?authMechanism=MONGODB-OIDC&authMechanismProperties=ALLOWED_HOSTS:%5B%22example.com%22%5D" + with self.assertRaises(ConfigurationError), warnings.catch_warnings(): + warnings.simplefilter("ignore") + _ = MongoClient( + uri, authmechanismproperties=dict(OIDC_HUMAN_CALLBACK=self.create_request_cb()) + ) + + def test_1_8_machine_idp_human_callback(self): + if not os.environ.get("OIDC_IS_LOCAL"): + raise unittest.SkipTest("Test Requires Local OIDC server") + # Create a client with MONGODB_URI_SINGLE, a username of test_machine, authMechanism=MONGODB-OIDC, and the OIDC human callback. + client = self.create_client(username="test_machine") + # Perform a find operation that succeeds. + client.test.test.find_one() + # Close the client. + client.close() + def test_2_1_valid_callback_inputs(self): # Create a MongoClient with a human callback that validates its inputs and returns a valid access token. client = self.create_client() @@ -228,7 +254,7 @@ class TestAuthOIDCHuman(OIDCTestBase): # Close the client. client.close() - def test_2_2_OIDC_HUMAN_CALLBACK_returns_missing_data(self): + def test_2_2_callback_returns_missing_data(self): # Create a MongoClient with a human callback that returns data not conforming to the OIDCCredential with missing fields. class CustomCB(OIDCCallback): def fetch(self, ctx): @@ -241,6 +267,29 @@ class TestAuthOIDCHuman(OIDCTestBase): # Close the client. client.close() + def test_2_3_refresh_token_is_passed_to_the_callback(self): + # Create a MongoClient with a human callback that checks for the presence of a refresh token. + client = self.create_client() + + # Perform a find operation that succeeds. + client.test.test.find_one() + + # Set a fail point for ``find`` commands. + with self.fail_point( + { + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "errorCode": 391}, + } + ): + # Perform a ``find`` operation that succeeds. + client.test.test.find_one() + + # Assert that the callback has been called twice. + self.assertEqual(self.request_called, 2) + + # Assert that the refresh token was used once. + self.assertEqual(self.refresh_present, 1) + def test_3_1_uses_speculative_authentication_if_there_is_a_cached_token(self): # Create a client with a human callback that returns a valid token. client = self.create_client() @@ -259,8 +308,8 @@ class TestAuthOIDCHuman(OIDCTestBase): # Set a fail point for ``saslStart`` commands. with self.fail_point( { - "mode": "alwaysOn", - "data": {"failCommands": ["saslStart"], "errorCode": 20}, + "mode": {"times": 1}, + "data": {"failCommands": ["saslStart"], "errorCode": 18}, } ): # Perform a ``find`` operation that succeeds @@ -276,8 +325,8 @@ class TestAuthOIDCHuman(OIDCTestBase): # Set a fail point for ``saslStart`` commands. with self.fail_point( { - "mode": "alwaysOn", - "data": {"failCommands": ["saslStart"], "errorCode": 20}, + "mode": {"times": 1}, + "data": {"failCommands": ["saslStart"], "errorCode": 18}, } ): # Perform a ``find`` operation that fails. @@ -378,8 +427,16 @@ class TestAuthOIDCHuman(OIDCTestBase): client.close() def test_4_3_reauthenticate_succeeds_after_refresh_fails(self): - # Create a client with a human callback that returns a valid token. - client = self.create_client() + # Create a default OIDC client with a human callback that returns an invalid refresh token + cb = self.create_request_cb() + + class CustomRequest(OIDCCallback): + def fetch(self, *args, **kwargs): + result = cb.fetch(*args, **kwargs) + result.refresh_token = "bad" + return result + + client = self.create_client(request_cb=CustomRequest()) # Perform a find operation that succeeds. client.test.test.find_one() @@ -390,38 +447,56 @@ class TestAuthOIDCHuman(OIDCTestBase): # Force a reauthenication using a fail point. with self.fail_point( { - "mode": {"times": 2}, - "data": {"failCommands": ["find", "saslStart"], "errorCode": 391}, + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "errorCode": 391}, } ): # Perform a find operation that succeeds. client.test.test.find_one() - # Assert that the human callback has been called 3 times. - self.assertEqual(self.request_called, 3) + # Assert that the human callback has been called 2 times. + self.assertEqual(self.request_called, 2) # Close the client. client.close() def test_4_4_reauthenticate_fails(self): - # Create a client with a human callback that returns a valid token. - client = self.create_client() + # Create a default OIDC client with a human callback that returns invalid refresh tokens and + # Returns invalid access tokens after the first access. + cb = self.create_request_cb() + + class CustomRequest(OIDCCallback): + fetch_called = 0 + + def fetch(self, *args, **kwargs): + self.fetch_called += 1 + result = cb.fetch(*args, **kwargs) + result.refresh_token = "bad" + if self.fetch_called > 1: + result.access_token = "bad" + return result + + client = self.create_client(request_cb=CustomRequest()) + # Perform a find operation that succeeds (to force a speculative auth). client.test.test.find_one() # Assert that the human callback has been called once. self.assertEqual(self.request_called, 1) + # Force a reauthentication using a failCommand. with self.fail_point( { - "mode": {"times": 3}, - "data": {"failCommands": ["find", "saslStart"], "errorCode": 391}, + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "errorCode": 391}, } ): # Perform a find operation that fails. with self.assertRaises(OperationFailure): client.test.test.find_one() - # Assert that the human callback has been called two times. - self.assertEqual(self.request_called, 2) + + # Assert that the human callback has been called three times. + self.assertEqual(self.request_called, 3) + # Close the client. client.close() @@ -818,6 +893,33 @@ class TestAuthOIDCMachine(OIDCTestBase): # Close the client. client.close() + def test_3_3_unexpected_error_code_does_not_clear_cache(self): + # Create a ``MongoClient`` with a human callback that returns a valid token + client = self.create_client() + + # Set a fail point for ``saslStart`` commands. + with self.fail_point( + { + "mode": {"times": 1}, + "data": {"failCommands": ["saslStart"], "errorCode": 20}, + } + ): + # Perform a ``find`` operation that fails. + with self.assertRaises(OperationFailure): + client.test.test.find_one() + + # Assert that the callback has been called once. + self.assertEqual(self.request_called, 1) + + # Perform a ``find`` operation that succeeds. + client.test.test.find_one() + + # Assert that the callback has been called once. + self.assertEqual(self.request_called, 1) + + # Close the client. + client.close() + def test_4_reauthentication(self): # Create a ``MongoClient`` configured with a custom OIDC callback that # implements the provider logic.