Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,20 @@ Notes and requirements:
``os.PathLike`` objects (including ``pathlib.Path``); these replace the default
trust roots. ``True`` or ``None`` selects the Requests CA bundle, while
``False`` disables server verification.
* For a Federated Identity Credential (FIC) exchange, configure the leg-2 client
with both a ``client_assertion`` callable (acquiring the leg-1 token) and an
``mtls_binding_certificate`` sub-key; the leg-1 assertion is then sent as a
``jwt-pop`` client assertion over mTLS using the same certificate as leg 1.
A fixed assertion string expires and emits a deprecation warning; the callable
lets the leg-1 application reuse cached tokens and acquire fresh ones as needed.
The target application must have a federated identity credential trusting the
leg-1 token's issuer, subject, and audience. See
``fic_two_leg_over_mtls_pop()`` in
``sample/confidential_client_mtls_pop_sample.py`` for the two-leg setup.
A final ``mtls_pop`` result includes an ``MtlsBindingContext`` for the
downstream resource connection. If leg 2 does not request PoP, its bound
assertion still travels as ``jwt-pop`` over mTLS, but the final Bearer token
is not certificate-bound and does not include ``binding_certificate``.

mTLS-bound access tokens are persisted in a separate ``AccessToken_mTLS`` cache
bucket. Older SDK versions ignore that bucket and retain their normal Bearer
Expand Down
234 changes: 184 additions & 50 deletions msal/application.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions msal/oauth2cli/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def encode_saml_assertion(assertion):

CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
CLIENT_ASSERTION_TYPE_SAML2 = "urn:ietf:params:oauth:client-assertion-type:saml2-bearer"
CLIENT_ASSERTION_TYPE_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop"
client_assertion_encoders = {CLIENT_ASSERTION_TYPE_SAML2: encode_saml_assertion}

@property
Expand Down
87 changes: 87 additions & 0 deletions sample/confidential_client_mtls_pop_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
mutual-TLS (mTLS) handshake to Microsoft Entra, and obtains an mTLS-bound
Proof-of-Possession (PoP) access token (``token_type == "mtls_pop"``).

It also shows the optional two-leg Federated Identity Credential (FIC) exchange
over mTLS PoP.

Prerequisites
-------------
* A confidential-client app registration configured with a certificate.
Expand All @@ -25,6 +28,17 @@
RESOURCE_URL optional mTLS resource URL to call with the acquired token
AZURE_REGION (optional) e.g. westus3; omission honors MSAL_FORCE_REGION

For the optional FIC sample, also configure::

RUN_FIC_SAMPLE set to 1 to run the two-leg exchange
LEG2_CLIENT_ID target application with a provisioned federated identity credential
LEG2_AUTHORITY tenanted authority for that target application
FIC_EXCHANGE_SCOPE (optional) defaults to api://AzureADTokenExchange/.default

The leg-1 app must be authorized for mTLS PoP at the exchange audience. The
leg-2 app's federated identity credential must trust the leg-1 token's issuer,
subject, and audience. Both legs use the SAME local certificate.

Then run::

python confidential_client_mtls_pop_sample.py
Expand Down Expand Up @@ -110,6 +124,79 @@ def vanilla_sni_mtls_pop():
return result


def fic_two_leg_over_mtls_pop():
"""Optional: two-leg Federated Identity Credential (FIC) exchange over mTLS PoP.

Leg 1: the SN/I confidential client acquires a cert-bound mtls_pop token for
the token-exchange audience.
Leg 2: a second confidential client presents that token as a ``jwt-pop``
client assertion, over an mTLS connection bound by the SAME cert, to
obtain the final token. Requesting PoP produces ``mtls_pop`` and a
binding context; omitting PoP produces Bearer without one.
"""
cert = _build_cert_credential()

# --- Leg 1: acquire the federated (cert-bound) assertion ---------------
leg1_app = msal.ConfidentialClientApplication(
os.environ["CLIENT_ID"],
authority=os.environ["AUTHORITY"],
client_credential=cert,
azure_region=os.getenv("AZURE_REGION"),
)
# The exchange audience is caller-supplied, not hard-coded by MSAL.
exchange_scope = os.getenv(
"FIC_EXCHANGE_SCOPE", "api://AzureADTokenExchange/.default")

def get_leg1_assertion():
# Reuse the leg-1 cache, acquiring a fresh token when the old one expires.
leg1 = leg1_app.acquire_token_for_client(
[exchange_scope], mtls_proof_of_possession=True)
if "access_token" not in leg1:
_print_result_summary(leg1, "FIC leg 1 result:")
raise RuntimeError(
"FIC leg 1 failed: {}".format(leg1.get("error")))
return leg1["access_token"]

# --- Leg 2: exchange the leg-1 token for the final token ---------------
leg2_app = msal.ConfidentialClientApplication(
os.environ["LEG2_CLIENT_ID"],
authority=os.environ["LEG2_AUTHORITY"],
client_credential={
"client_assertion": get_leg1_assertion,
"mtls_binding_certificate": cert,
},
azure_region=os.getenv("AZURE_REGION"),
)

# Final token as mTLS PoP. Omit mtls_proof_of_possession for a final Bearer
# token; its assertion still travels as jwt-pop over mTLS, but the result
# does not contain binding_certificate.
leg2 = leg2_app.acquire_token_for_client(
os.environ["SCOPE"].split(), mtls_proof_of_possession=True)
_print_result_summary(leg2, "FIC leg 2 result:")
resource_url = os.getenv("RESOURCE_URL")
if resource_url and "access_token" in leg2 and "binding_certificate" in leg2:
with leg2["binding_certificate"].create_requests_session() as session:
response = session.get(
resource_url,
headers={
"Authorization":
"{} {}".format(
leg2["token_type"],
leg2["access_token"],
),
},
timeout=30,
)
response.raise_for_status()
print(" resource call status: ", response.status_code)
return leg2


if __name__ == "__main__":
print("=== Vanilla SN/I -> mTLS PoP ===")
vanilla_sni_mtls_pop()

if os.getenv("RUN_FIC_SAMPLE"):
print("\n=== FIC two-leg over mTLS PoP ===")
fic_two_leg_over_mtls_pop()
24 changes: 24 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,30 @@ Notes:
from the MSID Lab `LabAuth` secret, without a PFX password. Missing, unreadable,
or invalid PFX files fail these scenarios; they never count as skipped coverage.

### FIC over mTLS lab parity

The FIC tests reuse the existing MSAL.NET/Go SN/I lab configuration for both
legs: app `163ffef9-a313-45b4-ab2f-c7e2f5e0e23e`, authority
`https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c`,
and the shared LabAuth certificate supplied through
`LAB_APP_CLIENT_CERT_PFX_PATH`. No Python-only `LAB_FIC_*` configuration or
new app provisioning is needed.

Leg 1 requests `api://AzureADTokenExchange/.default` as an mTLS PoP assertion.
The PoP-output cell uses global endpoints for both legs, requests
`https://graph.microsoft.com/.default`, and calls the mTLS Graph resource with
the final token's `binding_certificate`. The Bearer-output cell uses `westus3`
for both legs and requests `https://storage.azure.com/.default`; its final
token must not contain `binding_certificate` or trigger a downstream mTLS call.
Missing or invalid certificates and real token-acquisition errors fail the
FIC tests rather than skipping or accepting an error as success.

Run these two live cells with:

```powershell
python -m pytest -q tests\test_e2e.py::FicTwoLegTestCase
```

## 4) Run unit/integration tests

Run all non-E2E tests quickly:
Expand Down
Loading
Loading