Skip to content

Commit 3ce5d96

Browse files
Merge SNI mTLS PoP hardening (#938) into FIC branch
Bring the Task-1 SNI hardening from rginsburg/sni-mtls-pop (tip b9233c6) into the FIC follow-up branch: the reusable _call_graph_with_mtls_pop_token resource-call e2e helper, the cross-SDK naming matrix, the loud-fail on invalid_request, and the region split (drop the regional mtls_pop cell that intermittently downgrades to Bearer; make the Bearer cell regional westus3; keep the pop cell global). Conflicts resolved by keeping both sides: - docs/index.rst: retain the SNI note that only the mTLS-PoP path drops the signed client_assertion AND the FIC leg-2 configuration note. - msal/application.py: retain the FIC leg-2 jwt-pop assertion branch AND fold the SNI clarifying comment into the vanilla SN/I else path. The FIC e2e test and its leg-1 skip-hatch are left untouched; porting the resource-call helper into the FIC e2e is Task 2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents acd78ed + b9233c6 commit 3ce5d96

4 files changed

Lines changed: 85 additions & 15 deletions

File tree

docs/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,16 @@ Notes and requirements:
198198
* The ``authority`` must be **tenanted** (not ``/common`` or ``/organizations``).
199199
* MSAL must own the TLS transport, so a custom ``http_client`` is not supported
200200
together with ``mtls_proof_of_possession=True``.
201+
* ``azure_region`` is **optional**: omit it to use the global mTLS endpoint
202+
(``mtlsauth.microsoft.com``); set it (e.g. ``westus3``) only to pin a region.
201203
* The existing SN/I + Bearer (client-assertion) flow is unchanged; the same
202204
certificate can be used either as an assertion signer (Bearer) or as the TLS
203205
client certificate (mtls_pop).
204206
* mTLS PoP currently targets the public and Azure Government (Arlington) clouds.
207+
* Only the mTLS-PoP path drops the signed ``client_assertion``: the certificate
208+
authenticates the client through the mutual-TLS handshake instead. The classic
209+
(non-mTLS) certificate path is unchanged and still sends a signed
210+
``client_assertion`` (``private_key_jwt``) to obtain an ordinary token.
205211
* For a Federated Identity Credential (FIC) exchange, configure the leg-2 client
206212
with both a ``client_assertion`` (the leg-1 token) and an
207213
``mtls_binding_certificate`` sub-key; the leg-1 assertion is then sent as a

msal/application.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,11 @@ def _get_mtls_client(self, central_authority):
11041104
if self._mtls_is_fic_leg2: # FIC leg 2: assertion carried as jwt-pop
11051105
client_assertion = self.client_credential["client_assertion"]
11061106
client_assertion_type = Client.CLIENT_ASSERTION_TYPE_JWT_POP
1107-
else: # Vanilla SN/I: the TLS certificate alone authenticates the client
1107+
else:
1108+
# Vanilla SN/I: the TLS certificate alone authenticates the client, so
1109+
# this mTLS path sends NO signed client_assertion. (The classic, non-mTLS
1110+
# certificate path still signs a private_key_jwt client_assertion; only
1111+
# mTLS PoP replaces that assertion with the mutual-TLS handshake.)
11081112
client_assertion = None
11091113
client_assertion_type = None
11101114
client = _MtlsClient(

sample/confidential_client_mtls_pop_sample.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,12 @@ def _build_cert_credential():
5353
def _print_result_summary(result, header):
5454
"""Print a *log-safe* summary of an MSAL token ``result``.
5555
56-
We deliberately never log the ``result`` dict, nor any value read out of it,
57-
because on success it carries an ``access_token`` (and, for mTLS PoP, binding
58-
material). Logging token responses is exactly the habit a security library
59-
should not model. Instead we surface only booleans derived via comparison,
60-
which reveal the *shape* of the response without leaking its contents.
56+
The sensitive part of ``result`` is the ``access_token`` (a PoP/bearer
57+
secret) - that is what we must never log. The ``binding_certificate`` is the
58+
certificate's PUBLIC material (x5c + thumbprint), not a secret, so it is safe
59+
to inspect. We still avoid dumping the whole ``result`` dict and instead
60+
surface only a few derived booleans/fields, to model the habit of not
61+
routinely logging token responses.
6162
6263
On success the interesting (non-sensitive) fields are::
6364

tests/test_e2e.py

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,11 @@ def test_subject_name_issuer_authentication(self):
454454
# rejected for mTLS PoP with AADSTS700025. Mirrors the MSAL .NET/Java e2e config.
455455
_SNI_ALLOWLISTED_CLIENT_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e"
456456
_SNI_ALLOWLISTED_AUTHORITY = "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c"
457+
# The mTLS Graph host (NOT plain graph.microsoft.com): only this host performs
458+
# the client-certificate TLS handshake that an mtls_pop token's binding is
459+
# validated against. Presenting the token here, with the binding cert on the
460+
# handshake, must return 200; a 401/403 means the binding was lost.
461+
_MTLS_GRAPH_RESOURCE = "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1"
457462

458463
def _assert_pop_cache_hit(self, app, scope):
459464
# A second mTLS-PoP call for the same scope must be served from cache.
@@ -472,14 +477,52 @@ def _cnf_x5t_s256(access_token):
472477
except Exception: # Opaque/non-JWT token - skip the offline binding check
473478
return None
474479

475-
def test_sni_mtls_pop_for_client(self):
480+
def _call_graph_with_mtls_pop_token(self, token, cert_credential):
481+
"""Prove an ``mtls_pop`` token is genuinely usable: present it to the
482+
mTLS Graph endpoint over a TLS channel bound by the SAME certificate and
483+
require HTTP 200. Acquiring the token is not enough - a resource only
484+
honors it when the binding certificate is on the TLS handshake and the
485+
auth scheme is ``mtls_pop``; a 401/403 means the binding was lost, i.e. a
486+
real regression rather than a transient failure.
487+
488+
Shared test infrastructure: the FIC two-leg e2e in PR #939 reuses this
489+
as-is (please do not inline it), which is why it takes an explicit
490+
``cert_credential`` rather than reading ``self.app``'s certificate.
491+
"""
492+
from msal.application import _load_mtls_cert_material
493+
from msal.mtls import _create_ssl_context, _make_mtls_adapter
494+
material = _load_mtls_cert_material(cert_credential)
495+
session = requests.Session()
496+
session.mount("https://", _make_mtls_adapter(_create_ssl_context(
497+
material["cert_pem"], material["private_key_pem"])))
498+
try:
499+
resp = session.get(
500+
self._MTLS_GRAPH_RESOURCE,
501+
headers={"Authorization": "mtls_pop " + token},
502+
timeout=30)
503+
finally:
504+
session.close()
505+
self.assertEqual(
506+
200, resp.status_code,
507+
"mtls_pop token rejected by the resource (HTTP %s). A 401/403 means "
508+
"the binding certificate was not presented on the TLS handshake, or "
509+
"the 'mtls_pop' auth scheme was wrong. Body[:500]=%s" % (
510+
resp.status_code, (resp.text or "")[:500]))
511+
return resp
512+
513+
# ── Credential x Output matrix (mirrors the MSAL .NET e2e naming) ──────────
514+
# Credential_<X509|Fic>_Output_<Pop|Bearer>. Task 1 covers the two X509 cells
515+
# (this file); the Fic cells live in the FIC follow-up PR. snake_case per
516+
# unittest, but the matrix names line up 1:1 with the sibling SDKs.
517+
def test_credential_x509_output_pop(self):
476518
from tests.lab_config import get_client_certificate
477519
if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"):
478520
self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set")
521+
cert = get_client_certificate()
479522
self.app = msal.ConfidentialClientApplication(
480523
self._SNI_ALLOWLISTED_CLIENT_ID,
481524
authority=self._SNI_ALLOWLISTED_AUTHORITY,
482-
client_credential=get_client_certificate())
525+
client_credential=cert)
483526
result = self.app.acquire_token_for_client(
484527
self._MTLS_POP_SCOPE, mtls_proof_of_possession=True)
485528
self.assertIn("access_token", result, "mTLS PoP request failed: %s" % result)
@@ -490,23 +533,39 @@ def test_sni_mtls_pop_for_client(self):
490533
cnf = self._cnf_x5t_s256(result["access_token"])
491534
if cnf: # When the AT is a decodable JWT, its cnf must match our cert
492535
self.assertEqual(binding["thumbprint_sha256"], cnf)
536+
# The token must actually work at the resource, bound to our cert.
537+
self._call_graph_with_mtls_pop_token(result["access_token"], cert)
493538
self._assert_pop_cache_hit(self.app, self._MTLS_POP_SCOPE)
494539

495-
def test_sni_mtls_pop_for_client_regional(self):
540+
def test_credential_x509_output_bearer(self):
541+
# The SAME SN/I cert, but WITHOUT mtls_proof_of_possession, must still
542+
# authenticate via a signed client_assertion (private_key_jwt) and yield
543+
# an ordinary Bearer token. This proves the client_assertion is dropped
544+
# only on the mTLS-PoP path, never on the classic certificate path.
496545
from tests.lab_config import get_client_certificate
497546
if not clean_env("LAB_APP_CLIENT_CERT_PFX_PATH"):
498547
self.skipTest("LAB_APP_CLIENT_CERT_PFX_PATH not set")
499548
self.app = msal.ConfidentialClientApplication(
500549
self._SNI_ALLOWLISTED_CLIENT_ID,
501550
authority=self._SNI_ALLOWLISTED_AUTHORITY,
502551
client_credential=get_client_certificate(),
552+
# Regional is correct/desirable HERE and must stay: a Bearer
553+
# token_type is deterministic, so this retains live regional-endpoint
554+
# E2E coverage. Region is FORBIDDEN on the mtls_pop cell, where the
555+
# regional slice intermittently downgrades PoP -> Bearer (a Bearer
556+
# *token*, not a temporarily_unavailable error) - which is why all
557+
# sibling SDKs (.NET/Java/JS/Go) have no regional PoP cell either.
503558
azure_region="westus3")
504-
result = self.app.acquire_token_for_client(
505-
self._MTLS_POP_SCOPE, mtls_proof_of_possession=True)
506-
if result.get("error") in ("invalid_request", "temporarily_unavailable"):
507-
self.skipTest("Regional mTLS endpoint not reachable: %s" % result)
508-
self.assertIn("access_token", result, "Regional mTLS PoP failed: %s" % result)
509-
self.assertEqual("mtls_pop", result.get("token_type"))
559+
result = self.app.acquire_token_for_client(self._MTLS_POP_SCOPE)
560+
self.assertIn("access_token", result, "SN/I Bearer request failed: %s" % result)
561+
self.assertEqual("Bearer", result.get("token_type"))
562+
# Prove the cert binding is genuinely ABSENT, not merely that we skipped
563+
# the flag. MSAL Python adds binding_certificate ONLY on the mtls_pop
564+
# path, so assert both the SDK-internal contract (key omitted entirely)
565+
# and the caller-visible one (a lookup yields None) - no cert is bound.
566+
self.assertNotIn("binding_certificate", result)
567+
self.assertIsNone(result.get("binding_certificate"))
568+
self.assertCacheWorksForApp(result, self._MTLS_POP_SCOPE)
510569

511570
def test_fic_two_leg_over_mtls_pop(self):
512571
from tests.lab_config import get_client_certificate

0 commit comments

Comments
 (0)