Skip to content

Commit bf44364

Browse files
committed
Support SNI via PFX
Opt in via client_credential={ "public_certificate": True, "private_key_pfx_path": "/path/to/cert.pfx", }
1 parent c1fedad commit bf44364

File tree

3 files changed

+101
-51
lines changed

3 files changed

+101
-51
lines changed

msal/application.py

Lines changed: 91 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,23 @@ def _str2bytes(raw):
6161
return raw
6262

6363

64-
def _load_private_key_from_pfx_path(pfx_path, passphrase_bytes):
64+
def _parse_pfx(pfx_path, passphrase_bytes):
6565
# Cert concepts https://security.stackexchange.com/a/226758/125264
66-
from cryptography.hazmat.primitives import hashes
66+
from cryptography.hazmat.primitives import hashes, serialization
6767
from cryptography.hazmat.primitives.serialization import pkcs12
6868
with open(pfx_path, 'rb') as f:
6969
private_key, cert, _ = pkcs12.load_key_and_certificates( # cryptography 2.5+
7070
# https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.pkcs12.load_key_and_certificates
7171
f.read(), passphrase_bytes)
72+
if not (private_key and cert):
73+
raise ValueError("Your PFX file shall contain both private key and cert")
74+
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM).decode() # cryptography 1.0+
75+
x5c = [
76+
'\n'.join(cert_pem.splitlines()[1:-1]) # Strip the "--- header ---" and "--- footer ---"
77+
]
7278
sha1_thumbprint = cert.fingerprint(hashes.SHA1()).hex() # cryptography 0.7+
7379
# https://cryptography.io/en/latest/x509/reference/#x-509-certificate-object
74-
return private_key, sha1_thumbprint
80+
return private_key, sha1_thumbprint, x5c
7581

7682

7783
def _load_private_key_from_pem_str(private_key_pem_str, passphrase_bytes):
@@ -231,47 +237,71 @@ def __init__(
231237
232238
:param client_credential:
233239
For :class:`PublicClientApplication`, you use `None` here.
240+
234241
For :class:`ConfidentialClientApplication`,
235-
it can be a string containing client secret,
236-
or an X509 certificate container in this form::
242+
it supports many different input formats for different scenarios.
237243
238-
{
239-
"private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format",
240-
"thumbprint": "A1B2C3D4E5F6...",
241-
"public_certificate": "...-----BEGIN CERTIFICATE-----... (Optional. See below.)",
242-
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
243-
}
244+
.. admonition:: Support using a client secret.
244245
245-
MSAL Python requires a "private_key" in PEM format.
246-
If your cert is in a PKCS12 (.pfx) format, you can also
247-
`convert it to PEM and get the thumbprint <https://github.com/Azure/azure-sdk-for-python/blob/07d10639d7e47f4852eaeb74aef5d569db499d6e/sdk/identity/azure-identity/azure/identity/_credentials/certificate.py#L101-L123>`_.
246+
Just feed in a string, such as ``"your client secret"``.
248247
249-
The thumbprint is available in your app's registration in Azure Portal.
250-
Alternatively, you can `calculate the thumbprint <https://github.com/Azure/azure-sdk-for-python/blob/07d10639d7e47f4852eaeb74aef5d569db499d6e/sdk/identity/azure-identity/azure/identity/_credentials/certificate.py#L94-L97>`_.
248+
.. admonition:: Support using a certificate in X.509 (.pem) format
251249
252-
*Added in version 0.5.0*:
253-
public_certificate (optional) is public key certificate
254-
which will be sent through 'x5c' JWT header only for
255-
subject name and issuer authentication to support cert auto rolls.
256-
257-
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
258-
"the certificate containing
259-
the public key corresponding to the key used to digitally sign the
260-
JWS MUST be the first certificate. This MAY be followed by
261-
additional certificates, with each subsequent certificate being the
262-
one used to certify the previous one."
263-
However, your certificate's issuer may use a different order.
264-
So, if your attempt ends up with an error AADSTS700027 -
265-
"The provided signature value did not match the expected signature value",
266-
you may try use only the leaf cert (in PEM/str format) instead.
267-
268-
*Added in version 1.13.0*:
269-
It can also be a completely pre-signed assertion that you've assembled yourself.
270-
Simply pass a container containing only the key "client_assertion", like this::
250+
Feed in a dict in this form::
271251
272-
{
273-
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
274-
}
252+
{
253+
"private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format",
254+
"thumbprint": "A1B2C3D4E5F6...",
255+
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
256+
}
257+
258+
MSAL Python requires a "private_key" in PEM format.
259+
If your cert is in PKCS12 (.pfx) format,
260+
you can convert it to X.509 (.pem) format,
261+
by ``openssl pkcs12 -in file.pfx -out file.pem -nodes``.
262+
263+
The thumbprint is available in your app's registration in Azure Portal.
264+
Alternatively, you can `calculate the thumbprint <https://github.com/Azure/azure-sdk-for-python/blob/07d10639d7e47f4852eaeb74aef5d569db499d6e/sdk/identity/azure-identity/azure/identity/_credentials/certificate.py#L94-L97>`_.
265+
266+
.. admonition:: Support Subject Name/Issuer Auth with a cert in .pem
267+
268+
`Subject Name/Issuer Auth
269+
<https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/60>`_
270+
is an approach to allow easier certificate rotation.
271+
272+
*Added in version 0.5.0*::
273+
274+
{
275+
"private_key": "...-----BEGIN PRIVATE KEY-----... in PEM format",
276+
"thumbprint": "A1B2C3D4E5F6...",
277+
"public_certificate": "...-----BEGIN CERTIFICATE-----...",
278+
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
279+
}
280+
281+
``public_certificate`` (optional) is public key certificate
282+
which will be sent through 'x5c' JWT header only for
283+
subject name and issuer authentication to support cert auto rolls.
284+
285+
Per `specs <https://tools.ietf.org/html/rfc7515#section-4.1.6>`_,
286+
"the certificate containing
287+
the public key corresponding to the key used to digitally sign the
288+
JWS MUST be the first certificate. This MAY be followed by
289+
additional certificates, with each subsequent certificate being the
290+
one used to certify the previous one."
291+
However, your certificate's issuer may use a different order.
292+
So, if your attempt ends up with an error AADSTS700027 -
293+
"The provided signature value did not match the expected signature value",
294+
you may try use only the leaf cert (in PEM/str format) instead.
295+
296+
.. admonition:: Supporting raw assertion obtained from elsewhere
297+
298+
*Added in version 1.13.0*:
299+
It can also be a completely pre-signed assertion that you've assembled yourself.
300+
Simply pass a container containing only the key "client_assertion", like this::
301+
302+
{
303+
"client_assertion": "...a JWT with claims aud, exp, iss, jti, nbf, and sub..."
304+
}
275305
276306
.. admonition:: Supporting reading client cerficates from PFX files
277307
@@ -280,14 +310,26 @@ def __init__(
280310
281311
{
282312
"private_key_pfx_path": "/path/to/your.pfx",
283-
"passphrase": "Passphrase if the private_key is encrypted (Optional. Added in version 1.6.0)",
313+
"passphrase": "Passphrase if the private_key is encrypted (Optional)",
284314
}
285315
286316
The following command will generate a .pfx file from your .key and .pem file::
287317
288318
openssl pkcs12 -export -out certificate.pfx -inkey privateKey.key -in certificate.pem
289319
290-
:type client_credential: Union[dict, str]
320+
.. admonition:: Support Subject Name/Issuer Auth with a cert in .pfx
321+
322+
*Added in version 1.30.0*:
323+
If your .pfx file contains both the private key and public cert,
324+
you can opt in for Subject Name/Issuer Auth like this::
325+
326+
{
327+
"private_key_pfx_path": "/path/to/your.pfx",
328+
"public_certificate": True,
329+
"passphrase": "Passphrase if the private_key is encrypted (Optional)",
330+
}
331+
332+
:type client_credential: Union[dict, str, None]
291333
292334
:param dict client_claims:
293335
*Added in version 0.5.0*:
@@ -699,14 +741,15 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
699741
client_assertion = client_credential['client_assertion']
700742
else:
701743
headers = {}
702-
if client_credential.get('public_certificate'):
703-
headers["x5c"] = extract_certs(client_credential['public_certificate'])
704744
passphrase_bytes = _str2bytes(
705745
client_credential["passphrase"]
706746
) if client_credential.get("passphrase") else None
707747
if client_credential.get("private_key_pfx_path"):
708-
private_key, sha1_thumbprint = _load_private_key_from_pfx_path(
709-
client_credential["private_key_pfx_path"], passphrase_bytes)
748+
private_key, sha1_thumbprint, x5c = _parse_pfx(
749+
client_credential["private_key_pfx_path"],
750+
passphrase_bytes)
751+
if client_credential.get("public_certificate") is True and x5c:
752+
headers["x5c"] = x5c
710753
elif (
711754
client_credential.get("private_key") # PEM blob
712755
and client_credential.get("thumbprint")):
@@ -720,6 +763,10 @@ def _build_client(self, client_credential, authority, skip_regional_client=False
720763
raise ValueError(
721764
"client_credential needs to follow this format "
722765
"https://msal-python.readthedocs.io/en/latest/#msal.ClientApplication.params.client_credential")
766+
if ("x5c" not in headers # So we did not run the pfx code path
767+
and isinstance(client_credential.get('public_certificate'), str)
768+
): # Then we treat the public_certificate value as PEM content
769+
headers["x5c"] = extract_certs(client_credential['public_certificate'])
723770
assertion = JwtAssertionCreator(
724771
private_key, algorithm="RS256",
725772
sha1_thumbprint=sha1_thumbprint, headers=headers)

tests/test_cryptography.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import requests
99

1010
from msal.application import (
11-
_str2bytes, _load_private_key_from_pem_str, _load_private_key_from_pfx_path)
11+
_str2bytes, _load_private_key_from_pem_str, _parse_pfx)
1212

1313

1414
latest_cryptography_version = ET.fromstring(
@@ -48,7 +48,7 @@ def test_latest_cryptography_should_support_our_usage_without_warnings(self):
4848
_load_private_key_from_pem_str(f.read(), passphrase_bytes)
4949
pfx = sibling("certificate-with-password.pfx") # Created by:
5050
# openssl pkcs12 -export -inkey test/certificate-with-password.pem -in tests/certificate-with-password.pem -out tests/certificate-with-password.pfx
51-
_load_private_key_from_pfx_path(pfx, passphrase_bytes)
51+
_parse_pfx(pfx, passphrase_bytes)
5252
self.assertEqual(0, len(encountered_warnings),
5353
"Did cryptography deprecate the functions that we used?")
5454

tests/test_e2e.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _get_hint(html_mode=None, username=None, lab_name=None, username_uri=None):
8080
else "the upn from {}".format(_render(
8181
username_uri, description="here" if html_mode else None)),
8282
lab=_render(
83-
"https://aka.ms/GetLabUserSecret?Secret=" + (lab_name or "msidlabXYZ"),
83+
"https://aka.ms/GetLabSecret?Secret=" + (lab_name or "msidlabXYZ"),
8484
description="this password api" if html_mode else None,
8585
),
8686
)
@@ -463,7 +463,10 @@ def get_lab_app(
463463
# id came from https://docs.msidlab.com/accounts/confidentialclient.html
464464
client_id = os.getenv(env_client_id)
465465
# Cert came from https://ms.portal.azure.com/#@microsoft.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Certificate/https://msidlabs.vault.azure.net/certificates/LabVaultAccessCert
466-
client_credential = {"private_key_pfx_path": os.getenv(env_client_cert_path)}
466+
client_credential = {
467+
"private_key_pfx_path": os.getenv(env_client_cert_path),
468+
"public_certificate": True, # Opt in for SNI
469+
}
467470
elif os.getenv(env_client_id) and os.getenv(env_name2):
468471
# Data came from here
469472
# https://docs.msidlab.com/accounts/confidentialclient.html
@@ -529,7 +532,7 @@ def get_lab_user_secret(cls, lab_name="msidlab4"):
529532
lab_name = lab_name.lower()
530533
if lab_name not in cls._secrets:
531534
logger.info("Querying lab user password for %s", lab_name)
532-
url = "https://msidlab.com/api/LabUserSecret?secret=%s" % lab_name
535+
url = "https://msidlab.com/api/LabSecret?secret=%s" % lab_name
533536
resp = cls.session.get(url)
534537
cls._secrets[lab_name] = resp.json()["value"]
535538
return cls._secrets[lab_name]
@@ -860,7 +863,7 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self):
860863
861864
# https://msidlab.com/api/user?usertype=onprem&federationprovider=ADFSv2019
862865
username = "..." # The upn from the link above
863-
password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabXYZ
866+
password="***" # From https://aka.ms/GetLabSecret?Secret=msidlabXYZ
864867
"""
865868
config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019")
866869
config["authority"] = "https://fs.%s.com/adfs" % config["lab_name"]
@@ -953,7 +956,7 @@ def test_b2c_acquire_token_by_auth_code(self):
953956
954957
username="[email protected]"
955958
# This won't work https://msidlab.com/api/user?usertype=b2c
956-
password="***" # From https://aka.ms/GetLabUserSecret?Secret=msidlabb2c
959+
password="***" # From https://aka.ms/GetLabSecret?Secret=msidlabb2c
957960
"""
958961
config = self.get_lab_app_object(azureenvironment="azureb2ccloud")
959962
self._test_acquire_token_by_auth_code(

0 commit comments

Comments
 (0)