Skip to content
43 changes: 33 additions & 10 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down Expand Up @@ -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
Expand All @@ -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 "
Expand All @@ -846,13 +850,32 @@ 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
# Authority classification:
# - ADFS: authority.is_adfs
# - B2C: authority._is_b2c (and not OIDC)
# - CIAM: authority._is_b2c (and not OIDC)
# - OIDC generic: authority._is_oidc (includes dSTS)
# - AAD: everything else
# Use SHA256 for AAD, B2C, CIAM; use SHA1 for ADFS, OIDC generic, and dSTS
use_sha256 = False
if sha256_thumbprint and sha1_thumbprint:
# Both thumbprints provided - choose based on authority type
is_oidc = getattr(authority, '_is_oidc', False)
# Use SHA1 for ADFS, OIDC generic (including dSTS); SHA256 for everything else (AAD, B2C, CIAM)
use_sha256 = not authority.is_adfs and not is_oidc
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,
}
Expand Down
2 changes: 2 additions & 0 deletions msal/authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ def _initialize_oidc_authority(self, oidc_authority_url):
self._is_b2c = True # Not exactly true, but
# OIDC Authority was designed for CIAM which is the next gen of B2C.
# Besides, application.py uses this to bypass broker.
self._is_oidc = True # Track that this is a generic OIDC authority
self._is_known_to_developer = True # Not really relevant, but application.py uses this to bypass authority validation
return oidc_authority_url + "/.well-known/openid-configuration"

Expand All @@ -126,6 +127,7 @@ def _initialize_entra_authority(
self._is_b2c = any(
self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
self._is_oidc = False # This is not a generic OIDC authority
self._is_known_to_developer = self.is_adfs or self._is_b2c or not validate_authority
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only
Expand Down
239 changes: 232 additions & 7 deletions tests/test_optional_thumbprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand All @@ -90,7 +97,8 @@ def test_pem_with_certificate_only_uses_sha256(
self, mock_extract, mock_load_cert, mock_jwt_creator_class, mock_authority_class):
"""Test that providing only public_certificate (no thumbprint) uses SHA-256"""
authority = "https://login.microsoftonline.com/common"
self._setup_mocks(mock_authority_class, authority)
mock_authority = self._setup_mocks(mock_authority_class, authority)
mock_authority._is_oidc = False # AAD is not OIDC generic
self._setup_certificate_mocks(mock_extract, mock_load_cert)

# Create app with certificate credential WITHOUT thumbprint
Expand Down Expand Up @@ -119,12 +127,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
Expand All @@ -135,7 +142,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(
Expand All @@ -145,12 +152,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
Expand All @@ -161,7 +167,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
)

Expand Down Expand Up @@ -210,6 +216,225 @@ 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"
mock_authority = self._setup_mocks(mock_authority_class, authority)
mock_authority._is_oidc = False # AAD is not OIDC generic

# 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
mock_authority._is_oidc = False # B2C is not OIDC generic

# 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)
mock_authority._is_b2c = True # CIAM sets _is_b2c to True
mock_authority._is_oidc = False # CIAM is not OIDC generic

# 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, OIDC generic authority uses SHA-1"""
authority = "https://custom.oidc.authority.com/tenant"
mock_authority = self._setup_mocks(mock_authority_class, authority)

# Set up as an OIDC generic authority
mock_authority.is_adfs = False
mock_authority._is_b2c = True # OIDC sets this but it's not truly B2C
mock_authority._is_oidc = True # This distinguishes OIDC from B2C/CIAM

# Create app with BOTH thumbprints for OIDC 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 OIDC 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
)

def test_pem_with_both_thumbprints_dsts_uses_sha1(
self, mock_jwt_creator_class, mock_authority_class):
"""Test that with both thumbprints, dSTS authority uses SHA-1"""
authority = "https://test-instance1-dsts.dsts.core.azure-test.net/dstsv2/common"
mock_authority = self._setup_mocks(mock_authority_class, authority)

# Set up as a dSTS authority (dSTS is treated as OIDC)
mock_authority.is_adfs = False
mock_authority._is_b2c = True # OIDC sets this but it's not truly B2C
mock_authority._is_oidc = True # dSTS is treated as OIDC generic

# Create app with BOTH thumbprints for dSTS 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 dSTS 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
)

def test_pem_with_both_thumbprints_unknown_aad_uses_sha256(
self, mock_jwt_creator_class, mock_authority_class):
"""Test that with both thumbprints, unknown AAD authority (e.g., sovereign cloud) uses SHA-256"""
authority = "https://login.microsoftonline.de/tenant" # Example of sovereign cloud not in known list
mock_authority = self._setup_mocks(mock_authority_class, authority)

# Set up as an AAD authority (not ADFS, not B2C, not OIDC)
mock_authority.is_adfs = False
mock_authority._is_b2c = False
mock_authority._is_oidc = False

# Create app with BOTH thumbprints for unknown AAD 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 AAD authorities (even unknown ones), 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
)


if __name__ == "__main__":
unittest.main()
Loading