diff --git a/msal/application.py b/msal/application.py index ba16df83..cb0ff7fe 100644 --- a/msal/application.py +++ b/msal/application.py @@ -300,6 +300,10 @@ def __init__( "Changed in version 1.35.0, if thumbprint is absent" "and a public_certificate is present, MSAL will" "automatically calculate an SHA-256 thumbprint instead.", + "thumbprint_sha256": "An SHA-256 thumbprint (Added in version 1.35.0). " + "If both thumbprint and thumbprint_sha256 are provided, " + "SHA-256 is used for AAD authorities (including B2C, CIAM), " + "and SHA-1 is used for ADFS and generic authorities.", "passphrase": "Needed if the private_key is encrypted (Added in version 1.6.0)", "public_certificate": "...-----BEGIN CERTIFICATE-----...", # Needed if you use Subject Name/Issuer auth. Added in version 0.5.0. } @@ -823,10 +827,10 @@ def _build_client(self, client_credential, authority, skip_regional_client=False ) # Determine thumbprints based on what's provided - if client_credential.get("thumbprint"): - # User provided a thumbprint - use it as SHA-1 (legacy/manual approach) - sha1_thumbprint = client_credential["thumbprint"] - sha256_thumbprint = None + if client_credential.get("thumbprint") or client_credential.get("thumbprint_sha256"): + # User provided one or both thumbprints - use them as-is + sha1_thumbprint = client_credential.get("thumbprint") + sha256_thumbprint = client_credential.get("thumbprint_sha256") elif isinstance(client_credential.get('public_certificate'), str): # No thumbprint provided, but we have a certificate to calculate thumbprints from cryptography import x509 @@ -836,8 +840,8 @@ def _build_client(self, client_credential, authority, skip_regional_client=False _extract_cert_and_thumbprints(cert)) else: raise ValueError( - "You must provide either 'thumbprint' or 'public_certificate' " - "from which the thumbprint can be calculated.") + "You must provide 'thumbprint' (SHA-1), 'thumbprint_sha256' (SHA-256), " + "or 'public_certificate' from which the thumbprint can be calculated.") else: raise ValueError( "client_credential needs to follow this format " @@ -846,13 +850,31 @@ def _build_client(self, client_credential, authority, skip_regional_client=False and isinstance(client_credential.get('public_certificate'), str) ): # Then we treat the public_certificate value as PEM content headers["x5c"] = extract_certs(client_credential['public_certificate']) - if sha256_thumbprint and not authority.is_adfs: + # Determine which thumbprint to use based on what's available and authority type + # Based on the feature requirement: + # - If both thumbprints are provided, use SHA256 for AAD authorities + # (including B2C, CIAM), and SHA1 for ADFS and generic authorities + use_sha256 = False + if sha256_thumbprint and sha1_thumbprint: + # Both thumbprints provided - choose based on authority type + # Use SHA256 for AAD (including B2C, CIAM), SHA1 for ADFS and generic + from .authority import WELL_KNOWN_AUTHORITY_HOSTS + is_known_aad = authority.instance in WELL_KNOWN_AUTHORITY_HOSTS + is_b2c_or_ciam = getattr(authority, '_is_b2c', False) + # Use SHA256 for known AAD, B2C, or CIAM; SHA1 for ADFS and generic + use_sha256 = (is_known_aad or is_b2c_or_ciam) and not authority.is_adfs + elif sha256_thumbprint: + # Only SHA256 provided + use_sha256 = True + else: + # Only SHA1 provided or fallback + use_sha256 = False + + if use_sha256: assertion_params = { "algorithm": "PS256", "sha256_thumbprint": sha256_thumbprint, } - else: # Fall back - if not sha1_thumbprint: - raise ValueError("You shall provide a thumbprint in SHA1.") + else: assertion_params = { "algorithm": "RS256", "sha1_thumbprint": sha1_thumbprint, } diff --git a/tests/test_optional_thumbprint.py b/tests/test_optional_thumbprint.py index 56ec4013..85538c12 100644 --- a/tests/test_optional_thumbprint.py +++ b/tests/test_optional_thumbprint.py @@ -21,6 +21,11 @@ class TestClientCredentialWithOptionalThumbprint(unittest.TestCase): BAMMC0V4YW1wbGUgQ0EwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAW -----END CERTIFICATE-----""" + # Test thumbprint values + test_sha1_thumbprint = "A1B2C3D4E5F6" + test_sha256_thumbprint = "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2" + + def _setup_mocks(self, mock_authority_class, authority="https://login.microsoftonline.com/common"): """Helper to setup Authority mock""" # Setup Authority mock @@ -71,6 +76,8 @@ def _verify_assertion_params(self, mock_jwt_creator_class, expected_algorithm, if expected_thumbprint_type == 'sha256': self.assertIn('sha256_thumbprint', call_args[1]) self.assertNotIn('sha1_thumbprint', call_args[1]) + if expected_thumbprint_value: + self.assertEqual(call_args[1]['sha256_thumbprint'], expected_thumbprint_value) elif expected_thumbprint_type == 'sha1': self.assertIn('sha1_thumbprint', call_args[1]) self.assertNotIn('sha256_thumbprint', call_args[1]) @@ -119,12 +126,11 @@ def test_pem_with_manual_thumbprint_uses_sha1( self._setup_mocks(mock_authority_class, authority) # Create app with manual thumbprint (legacy approach) - manual_thumbprint = "A1B2C3D4E5F6" app = ConfidentialClientApplication( client_id="my_client_id", client_credential={ "private_key": self.test_private_key, - "thumbprint": manual_thumbprint, + "thumbprint": self.test_sha1_thumbprint, # Note: NO public_certificate provided }, authority=authority @@ -135,7 +141,7 @@ def test_pem_with_manual_thumbprint_uses_sha1( mock_jwt_creator_class, expected_algorithm='RS256', expected_thumbprint_type='sha1', - expected_thumbprint_value=manual_thumbprint + expected_thumbprint_value=self.test_sha1_thumbprint ) def test_pem_with_both_uses_manual_thumbprint_as_sha1( @@ -145,12 +151,11 @@ def test_pem_with_both_uses_manual_thumbprint_as_sha1( self._setup_mocks(mock_authority_class, authority) # Create app with BOTH thumbprint and certificate - manual_thumbprint = "A1B2C3D4E5F6" app = ConfidentialClientApplication( client_id="my_client_id", client_credential={ "private_key": self.test_private_key, - "thumbprint": manual_thumbprint, + "thumbprint": self.test_sha1_thumbprint, "public_certificate": self.test_certificate, }, authority=authority @@ -161,7 +166,7 @@ def test_pem_with_both_uses_manual_thumbprint_as_sha1( mock_jwt_creator_class, expected_algorithm='RS256', expected_thumbprint_type='sha1', - expected_thumbprint_value=manual_thumbprint, + expected_thumbprint_value=self.test_sha1_thumbprint, has_x5c=True # x5c should still be present ) @@ -210,6 +215,160 @@ def test_pem_with_neither_raises_error(self, mock_jwt_creator_class, mock_author self.assertIn("thumbprint", str(context.exception).lower()) self.assertIn("public_certificate", str(context.exception).lower()) + def test_pem_with_thumbprint_sha256_only_uses_sha256( + self, mock_jwt_creator_class, mock_authority_class): + """Test that providing only thumbprint_sha256 uses SHA-256""" + authority = "https://login.microsoftonline.com/common" + self._setup_mocks(mock_authority_class, authority) + + # Create app with only SHA256 thumbprint + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # Verify SHA-256 with PS256 algorithm is used + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='PS256', + expected_thumbprint_type='sha256', + expected_thumbprint_value=self.test_sha256_thumbprint + ) + + def test_pem_with_both_thumbprints_aad_uses_sha256( + self, mock_jwt_creator_class, mock_authority_class): + """Test that with both thumbprints, AAD authority uses SHA-256""" + authority = "https://login.microsoftonline.com/common" + self._setup_mocks(mock_authority_class, authority) + + # Create app with BOTH thumbprints for AAD + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint": self.test_sha1_thumbprint, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # For AAD, should use SHA-256 when both are provided + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='PS256', + expected_thumbprint_type='sha256', + expected_thumbprint_value=self.test_sha256_thumbprint + ) + + def test_pem_with_both_thumbprints_adfs_uses_sha1( + self, mock_jwt_creator_class, mock_authority_class): + """Test that with both thumbprints, ADFS authority uses SHA-1""" + authority = "https://adfs.contoso.com/adfs" + self._setup_mocks(mock_authority_class, authority) + + # Create app with BOTH thumbprints for ADFS + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint": self.test_sha1_thumbprint, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # For ADFS, should use SHA-1 when both are provided + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='RS256', + expected_thumbprint_type='sha1', + expected_thumbprint_value=self.test_sha1_thumbprint + ) + + def test_pem_with_both_thumbprints_b2c_uses_sha256( + self, mock_jwt_creator_class, mock_authority_class): + """Test that with both thumbprints, B2C authority uses SHA-256""" + authority = "https://contoso.b2clogin.com/contoso.onmicrosoft.com/B2C_1_susi" + mock_authority = self._setup_mocks(mock_authority_class, authority) + mock_authority._is_b2c = True # Manually set _is_b2c to True for this B2C authority + + # Create app with BOTH thumbprints for B2C + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint": self.test_sha1_thumbprint, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # For B2C, should use SHA-256 when both are provided + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='PS256', + expected_thumbprint_type='sha256', + expected_thumbprint_value=self.test_sha256_thumbprint + ) + + def test_pem_with_both_thumbprints_ciam_uses_sha256( + self, mock_jwt_creator_class, mock_authority_class): + """Test that with both thumbprints, CIAM authority uses SHA-256""" + authority = "https://contoso.ciamlogin.com/contoso.onmicrosoft.com" + mock_authority = self._setup_mocks(mock_authority_class, authority) + + # Create app with BOTH thumbprints for CIAM + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint": self.test_sha1_thumbprint, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # For CIAM, should use SHA-256 when both are provided + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='PS256', + expected_thumbprint_type='sha256', + expected_thumbprint_value=self.test_sha256_thumbprint + ) + + def test_pem_with_both_thumbprints_generic_uses_sha1( + self, mock_jwt_creator_class, mock_authority_class): + """Test that with both thumbprints, generic authority uses SHA-1""" + authority = "https://custom.authority.com/tenant" + mock_authority = self._setup_mocks(mock_authority_class, authority) + + # Set up as a generic authority (not ADFS, not B2C, not in known hosts) + mock_authority.is_adfs = False + mock_authority._is_b2c = False + + # Create app with BOTH thumbprints for generic authority + app = ConfidentialClientApplication( + client_id="my_client_id", + client_credential={ + "private_key": self.test_private_key, + "thumbprint": self.test_sha1_thumbprint, + "thumbprint_sha256": self.test_sha256_thumbprint, + }, + authority=authority + ) + + # For generic authorities, should use SHA-1 when both are provided + self._verify_assertion_params( + mock_jwt_creator_class, + expected_algorithm='RS256', + expected_thumbprint_type='sha1', + expected_thumbprint_value=self.test_sha1_thumbprint + ) + if __name__ == "__main__": unittest.main()