| 
 | 1 | +import unittest  | 
 | 2 | +from unittest.mock import Mock, patch  | 
 | 3 | +from msal.application import ConfidentialClientApplication  | 
 | 4 | + | 
 | 5 | + | 
 | 6 | +@patch('msal.application.Authority')  | 
 | 7 | +@patch('msal.application.JwtAssertionCreator', new_callable=lambda: Mock(  | 
 | 8 | +    return_value=Mock(create_regenerative_assertion=Mock(return_value="mock_jwt_assertion"))))  | 
 | 9 | +class TestClientCredentialWithOptionalThumbprint(unittest.TestCase):  | 
 | 10 | +    """Test that thumbprint is optional when public_certificate is provided"""  | 
 | 11 | + | 
 | 12 | +    # Sample test certificate and private key (PEM format)  | 
 | 13 | +    # These are minimal valid PEM structures for testing  | 
 | 14 | +    test_private_key = """-----BEGIN PRIVATE KEY-----  | 
 | 15 | +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj  | 
 | 16 | +MzEfYyjiWA4R4/M2bS1+fWIcPm15j7uo6xKvRr4PNx5bKMDFqMdW6/xfqFWX0nZK  | 
 | 17 | +-----END PRIVATE KEY-----"""  | 
 | 18 | + | 
 | 19 | +    test_certificate = """-----BEGIN CERTIFICATE-----  | 
 | 20 | +MIIC5jCCAc6gAwIBAgIJALdYQVsVsNZHMA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV  | 
 | 21 | +BAMMC0V4YW1wbGUgQ0EwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAW  | 
 | 22 | +-----END CERTIFICATE-----"""  | 
 | 23 | + | 
 | 24 | +    def _setup_mocks(self, mock_authority_class, authority="https://login.microsoftonline.com/common"):  | 
 | 25 | +        """Helper to setup Authority mock"""  | 
 | 26 | +        # Setup Authority mock  | 
 | 27 | +        mock_authority = Mock()  | 
 | 28 | +        mock_authority.is_adfs = "adfs" in authority.lower()  | 
 | 29 | + | 
 | 30 | +        # Extract instance from authority URL  | 
 | 31 | +        if mock_authority.is_adfs:  | 
 | 32 | +            # For ADFS: https://adfs.contoso.com/adfs -> adfs.contoso.com  | 
 | 33 | +            mock_authority.instance = authority.split("//")[1].split("/")[0]  | 
 | 34 | +            mock_authority.token_endpoint = f"https://{mock_authority.instance}/adfs/oauth2/token"  | 
 | 35 | +            mock_authority.authorization_endpoint = f"https://{mock_authority.instance}/adfs/oauth2/authorize"  | 
 | 36 | +        else:  | 
 | 37 | +            # For AAD: https://login.microsoftonline.com/common -> login.microsoftonline.com  | 
 | 38 | +            mock_authority.instance = authority.split("//")[1].split("/")[0]  | 
 | 39 | +            mock_authority.token_endpoint = f"https://{mock_authority.instance}/common/oauth2/v2.0/token"  | 
 | 40 | +            mock_authority.authorization_endpoint = f"https://{mock_authority.instance}/common/oauth2/v2.0/authorize"  | 
 | 41 | + | 
 | 42 | +        mock_authority.device_authorization_endpoint = None  | 
 | 43 | +        mock_authority_class.return_value = mock_authority  | 
 | 44 | + | 
 | 45 | +        return mock_authority  | 
 | 46 | + | 
 | 47 | +    def _setup_certificate_mocks(self, mock_extract, mock_load_cert):  | 
 | 48 | +        """Helper to setup certificate parsing mocks"""  | 
 | 49 | +        # Mock certificate loading  | 
 | 50 | +        mock_cert = Mock()  | 
 | 51 | +        mock_load_cert.return_value = mock_cert  | 
 | 52 | + | 
 | 53 | +        # Mock _extract_cert_and_thumbprints to return thumbprints  | 
 | 54 | +        mock_extract.return_value = (  | 
 | 55 | +            "mock_sha256_thumbprint",  # sha256_thumbprint  | 
 | 56 | +            "mock_sha1_thumbprint",     # sha1_thumbprint  | 
 | 57 | +            ["mock_x5c_value"]          # x5c  | 
 | 58 | +        )  | 
 | 59 | + | 
 | 60 | +    def _verify_assertion_params(self, mock_jwt_creator_class, expected_algorithm,  | 
 | 61 | +                                  expected_thumbprint_type, expected_thumbprint_value=None,  | 
 | 62 | +                                  has_x5c=False):  | 
 | 63 | +        """Helper to verify JwtAssertionCreator was called with correct params"""  | 
 | 64 | +        mock_jwt_creator_class.assert_called_once()  | 
 | 65 | +        call_args = mock_jwt_creator_class.call_args  | 
 | 66 | + | 
 | 67 | +        # Verify algorithm  | 
 | 68 | +        self.assertEqual(call_args[1]['algorithm'], expected_algorithm)  | 
 | 69 | + | 
 | 70 | +        # Verify thumbprint type  | 
 | 71 | +        if expected_thumbprint_type == 'sha256':  | 
 | 72 | +            self.assertIn('sha256_thumbprint', call_args[1])  | 
 | 73 | +            self.assertNotIn('sha1_thumbprint', call_args[1])  | 
 | 74 | +        elif expected_thumbprint_type == 'sha1':  | 
 | 75 | +            self.assertIn('sha1_thumbprint', call_args[1])  | 
 | 76 | +            self.assertNotIn('sha256_thumbprint', call_args[1])  | 
 | 77 | +            if expected_thumbprint_value:  | 
 | 78 | +                self.assertEqual(call_args[1]['sha1_thumbprint'], expected_thumbprint_value)  | 
 | 79 | + | 
 | 80 | +        # Verify x5c header if expected  | 
 | 81 | +        if has_x5c:  | 
 | 82 | +            self.assertIn('headers', call_args[1])  | 
 | 83 | +            self.assertIn('x5c', call_args[1]['headers'])  | 
 | 84 | + | 
 | 85 | +        return call_args  | 
 | 86 | + | 
 | 87 | +    @patch('cryptography.x509.load_pem_x509_certificate')  | 
 | 88 | +    @patch('msal.application._extract_cert_and_thumbprints')  | 
 | 89 | +    def test_pem_with_certificate_only_uses_sha256(  | 
 | 90 | +            self, mock_extract, mock_load_cert, mock_jwt_creator_class, mock_authority_class):  | 
 | 91 | +        """Test that providing only public_certificate (no thumbprint) uses SHA-256"""  | 
 | 92 | +        authority = "https://login.microsoftonline.com/common"  | 
 | 93 | +        self._setup_mocks(mock_authority_class, authority)  | 
 | 94 | +        self._setup_certificate_mocks(mock_extract, mock_load_cert)  | 
 | 95 | + | 
 | 96 | +        # Create app with certificate credential WITHOUT thumbprint  | 
 | 97 | +        app = ConfidentialClientApplication(  | 
 | 98 | +            client_id="my_client_id",  | 
 | 99 | +            client_credential={  | 
 | 100 | +                "private_key": self.test_private_key,  | 
 | 101 | +                "public_certificate": self.test_certificate,  | 
 | 102 | +                # Note: NO thumbprint provided  | 
 | 103 | +            },  | 
 | 104 | +            authority=authority  | 
 | 105 | +        )  | 
 | 106 | + | 
 | 107 | +        # Verify SHA-256 with PS256 algorithm is used  | 
 | 108 | +        self._verify_assertion_params(  | 
 | 109 | +            mock_jwt_creator_class,  | 
 | 110 | +            expected_algorithm='PS256',  | 
 | 111 | +            expected_thumbprint_type='sha256',  | 
 | 112 | +            has_x5c=True  | 
 | 113 | +        )  | 
 | 114 | + | 
 | 115 | +    def test_pem_with_manual_thumbprint_uses_sha1(  | 
 | 116 | +            self, mock_jwt_creator_class, mock_authority_class):  | 
 | 117 | +        """Test that providing manual thumbprint (no certificate) uses SHA-1"""  | 
 | 118 | +        authority = "https://login.microsoftonline.com/common"  | 
 | 119 | +        self._setup_mocks(mock_authority_class, authority)  | 
 | 120 | + | 
 | 121 | +        # Create app with manual thumbprint (legacy approach)  | 
 | 122 | +        manual_thumbprint = "A1B2C3D4E5F6"  | 
 | 123 | +        app = ConfidentialClientApplication(  | 
 | 124 | +            client_id="my_client_id",  | 
 | 125 | +            client_credential={  | 
 | 126 | +                "private_key": self.test_private_key,  | 
 | 127 | +                "thumbprint": manual_thumbprint,  | 
 | 128 | +                # Note: NO public_certificate provided  | 
 | 129 | +            },  | 
 | 130 | +            authority=authority  | 
 | 131 | +        )  | 
 | 132 | + | 
 | 133 | +        # Verify SHA-1 with RS256 algorithm is used  | 
 | 134 | +        self._verify_assertion_params(  | 
 | 135 | +            mock_jwt_creator_class,  | 
 | 136 | +            expected_algorithm='RS256',  | 
 | 137 | +            expected_thumbprint_type='sha1',  | 
 | 138 | +            expected_thumbprint_value=manual_thumbprint  | 
 | 139 | +        )  | 
 | 140 | + | 
 | 141 | +    def test_pem_with_both_uses_manual_thumbprint_as_sha1(  | 
 | 142 | +            self, mock_jwt_creator_class, mock_authority_class):  | 
 | 143 | +        """Test that providing both thumbprint and certificate prefers manual thumbprint (SHA-1)"""  | 
 | 144 | +        authority = "https://login.microsoftonline.com/common"  | 
 | 145 | +        self._setup_mocks(mock_authority_class, authority)  | 
 | 146 | + | 
 | 147 | +        # Create app with BOTH thumbprint and certificate  | 
 | 148 | +        manual_thumbprint = "A1B2C3D4E5F6"  | 
 | 149 | +        app = ConfidentialClientApplication(  | 
 | 150 | +            client_id="my_client_id",  | 
 | 151 | +            client_credential={  | 
 | 152 | +                "private_key": self.test_private_key,  | 
 | 153 | +                "thumbprint": manual_thumbprint,  | 
 | 154 | +                "public_certificate": self.test_certificate,  | 
 | 155 | +            },  | 
 | 156 | +            authority=authority  | 
 | 157 | +        )  | 
 | 158 | + | 
 | 159 | +        # Verify manual thumbprint takes precedence (backward compatibility)  | 
 | 160 | +        self._verify_assertion_params(  | 
 | 161 | +            mock_jwt_creator_class,  | 
 | 162 | +            expected_algorithm='RS256',  | 
 | 163 | +            expected_thumbprint_type='sha1',  | 
 | 164 | +            expected_thumbprint_value=manual_thumbprint,  | 
 | 165 | +            has_x5c=True  # x5c should still be present  | 
 | 166 | +        )  | 
 | 167 | + | 
 | 168 | +    @patch('cryptography.x509.load_pem_x509_certificate')  | 
 | 169 | +    @patch('msal.application._extract_cert_and_thumbprints')  | 
 | 170 | +    def test_pem_with_adfs_uses_sha1(  | 
 | 171 | +            self, mock_extract, mock_load_cert, mock_jwt_creator_class, mock_authority_class):  | 
 | 172 | +        """Test that ADFS authority uses SHA-1 even with SHA-256 thumbprint"""  | 
 | 173 | +        authority = "https://adfs.contoso.com/adfs"  | 
 | 174 | +        self._setup_mocks(mock_authority_class, authority)  | 
 | 175 | +        self._setup_certificate_mocks(mock_extract, mock_load_cert)  | 
 | 176 | + | 
 | 177 | +        # Create app with certificate on ADFS  | 
 | 178 | +        app = ConfidentialClientApplication(  | 
 | 179 | +            client_id="my_client_id",  | 
 | 180 | +            client_credential={  | 
 | 181 | +                "private_key": self.test_private_key,  | 
 | 182 | +                "public_certificate": self.test_certificate,  | 
 | 183 | +            },  | 
 | 184 | +            authority=authority  | 
 | 185 | +        )  | 
 | 186 | + | 
 | 187 | +        # ADFS should force SHA-1 with RS256 even though SHA-256 would be calculated  | 
 | 188 | +        self._verify_assertion_params(  | 
 | 189 | +            mock_jwt_creator_class,  | 
 | 190 | +            expected_algorithm='RS256',  | 
 | 191 | +            expected_thumbprint_type='sha1'  | 
 | 192 | +        )  | 
 | 193 | + | 
 | 194 | +    def test_pem_with_neither_raises_error(self, mock_jwt_creator_class, mock_authority_class):  | 
 | 195 | +        """Test that providing neither thumbprint nor certificate raises ValueError"""  | 
 | 196 | +        authority = "https://login.microsoftonline.com/common"  | 
 | 197 | +        self._setup_mocks(mock_authority_class, authority)  | 
 | 198 | + | 
 | 199 | +        # Should raise ValueError when neither thumbprint nor certificate provided  | 
 | 200 | +        with self.assertRaises(ValueError) as context:  | 
 | 201 | +            app = ConfidentialClientApplication(  | 
 | 202 | +                client_id="my_client_id",  | 
 | 203 | +                client_credential={  | 
 | 204 | +                    "private_key": self.test_private_key,  | 
 | 205 | +                    # Note: NO thumbprint and NO public_certificate  | 
 | 206 | +                },  | 
 | 207 | +                authority=authority  | 
 | 208 | +            )  | 
 | 209 | + | 
 | 210 | +        self.assertIn("thumbprint", str(context.exception).lower())  | 
 | 211 | +        self.assertIn("public_certificate", str(context.exception).lower())  | 
 | 212 | + | 
 | 213 | + | 
 | 214 | +if __name__ == "__main__":  | 
 | 215 | +    unittest.main()  | 
0 commit comments