From 127019651d231546f9bc49878ca8d6a2377530a4 Mon Sep 17 00:00:00 2001 From: Mihir Pradhan Date: Wed, 8 Jul 2026 09:12:55 -0500 Subject: [PATCH 1/2] Add CA bundle version and pinning status to user agent string Co-Authored-By: Claude Opus 4.6 --- duo_universal/client.py | 12 +++++++--- tests/test_setup_client.py | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/duo_universal/client.py b/duo_universal/client.py index c6f6f42..b771781 100644 --- a/duo_universal/client.py +++ b/duo_universal/client.py @@ -45,6 +45,7 @@ OAUTH_V1_AUTHORIZE_ENDPOINT = "https://{}/oauth/v1/authorize" OAUTH_V1_TOKEN_ENDPOINT = "https://{}/oauth/v1/token" DEFAULT_CA_CERT_PATH = os.path.join(os.path.dirname(__file__), 'ca_certs.pem') +CA_BUNDLE_VERSION = "1.0" CLIENT_ASSERT_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" @@ -316,10 +317,15 @@ def exchange_authorization_code_for_2fa_result(self, duoCode, username, nonce=No algorithm='HS512') } try: + ca_pinning_status = "disabled" if self._disable_ca_pinning else "enabled" user_agent = ("duo_universal_python/{version} " - "python/{python_version} {os_name}").format(version=__version__, - python_version=platform.python_version(), - os_name=platform.platform()) + "python/{python_version} {os_name} " + "ca_bundle/{ca_bundle_version} " + "ca_pinning: {ca_pinning_status}").format(version=__version__, + python_version=platform.python_version(), + os_name=platform.platform(), + ca_bundle_version=CA_BUNDLE_VERSION, + ca_pinning_status=ca_pinning_status) response = requests.post(token_endpoint, params=all_args, headers={"user-agent": diff --git a/tests/test_setup_client.py b/tests/test_setup_client.py index 03dd04f..b39f569 100644 --- a/tests/test_setup_client.py +++ b/tests/test_setup_client.py @@ -253,5 +253,51 @@ def test_token_exchange_pinning_enabled_uses_bundled_certs(self, requests_mock): self.assertEqual(kwargs['verify'], client.DEFAULT_CA_CERT_PATH) +class TestUserAgent(unittest.TestCase): + + @patch('requests.post') + def test_user_agent_includes_ca_bundle_version(self, requests_mock): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'id_token': 'fake'} + requests_mock.return_value = mock_response + c = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + try: + c.exchange_authorization_code_for_2fa_result('code', 'user') + except client.DuoException: + pass + _, kwargs = requests_mock.call_args + self.assertIn('ca_bundle/1.0', kwargs['headers']['user-agent']) + + @patch('requests.post') + def test_user_agent_ca_pinning_enabled(self, requests_mock): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'id_token': 'fake'} + requests_mock.return_value = mock_response + c = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI) + try: + c.exchange_authorization_code_for_2fa_result('code', 'user') + except client.DuoException: + pass + _, kwargs = requests_mock.call_args + self.assertIn('ca_pinning: enabled', kwargs['headers']['user-agent']) + + @patch('requests.post') + def test_user_agent_ca_pinning_disabled(self, requests_mock): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'id_token': 'fake'} + requests_mock.return_value = mock_response + c = client.Client(CLIENT_ID, CLIENT_SECRET, HOST, REDIRECT_URI, + disable_ca_pinning=True) + try: + c.exchange_authorization_code_for_2fa_result('code', 'user') + except client.DuoException: + pass + _, kwargs = requests_mock.call_args + self.assertIn('ca_pinning: disabled', kwargs['headers']['user-agent']) + + if __name__ == '__main__': unittest.main() From c92baa51bca07589cd166868faabc501ca0ab3c3 Mon Sep 17 00:00:00 2001 From: Mihir Pradhan Date: Wed, 8 Jul 2026 13:09:44 -0500 Subject: [PATCH 2/2] Update ca_pinning syntax --- duo_universal/client.py | 10 +++++----- tests/test_setup_client.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/duo_universal/client.py b/duo_universal/client.py index b771781..c6241d7 100644 --- a/duo_universal/client.py +++ b/duo_universal/client.py @@ -321,11 +321,11 @@ def exchange_authorization_code_for_2fa_result(self, duoCode, username, nonce=No user_agent = ("duo_universal_python/{version} " "python/{python_version} {os_name} " "ca_bundle/{ca_bundle_version} " - "ca_pinning: {ca_pinning_status}").format(version=__version__, - python_version=platform.python_version(), - os_name=platform.platform(), - ca_bundle_version=CA_BUNDLE_VERSION, - ca_pinning_status=ca_pinning_status) + "(ca_pinning={ca_pinning_status})").format(version=__version__, + python_version=platform.python_version(), + os_name=platform.platform(), + ca_bundle_version=CA_BUNDLE_VERSION, + ca_pinning_status=ca_pinning_status) response = requests.post(token_endpoint, params=all_args, headers={"user-agent": diff --git a/tests/test_setup_client.py b/tests/test_setup_client.py index b39f569..296fc3a 100644 --- a/tests/test_setup_client.py +++ b/tests/test_setup_client.py @@ -281,7 +281,7 @@ def test_user_agent_ca_pinning_enabled(self, requests_mock): except client.DuoException: pass _, kwargs = requests_mock.call_args - self.assertIn('ca_pinning: enabled', kwargs['headers']['user-agent']) + self.assertIn('(ca_pinning=enabled)', kwargs['headers']['user-agent']) @patch('requests.post') def test_user_agent_ca_pinning_disabled(self, requests_mock): @@ -296,7 +296,7 @@ def test_user_agent_ca_pinning_disabled(self, requests_mock): except client.DuoException: pass _, kwargs = requests_mock.call_args - self.assertIn('ca_pinning: disabled', kwargs['headers']['user-agent']) + self.assertIn('(ca_pinning=disabled)', kwargs['headers']['user-agent']) if __name__ == '__main__':