@@ -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