Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
01b676b
Add FIC two-leg exchange over mTLS Proof-of-Possession
Robbie-Microsoft Jul 6, 2026
90d0e67
Fail fast when a FIC leg-2 app is used with a non-mTLS flow
Robbie-Microsoft Jul 8, 2026
acd78ed
Reject FIC leg-2 apps on silent flows too
Robbie-Microsoft Jul 8, 2026
3ce5d96
Merge SNI mTLS PoP hardening (#938) into FIC branch
Robbie-Microsoft Jul 30, 2026
09e8fb5
Merge resync'd #938 (origin/dev) into FIC branch #939
Robbie-Microsoft Jul 30, 2026
c78bb15
Merge updated sni-mtls-pop foundation
Robbie-Microsoft Oct 9, 2026
1e41f24
Address FIC mTLS review feedback
Robbie-Microsoft Oct 22, 2026
6270370
Merge updated sni-mtls-pop foundation
Robbie-Microsoft Oct 22, 2026
c39abc6
Merge mTLS binding context foundation
Robbie-Microsoft Oct 22, 2026
cd043fe
Merge remote-tracking branch 'origin/rginsburg/sni-mtls-pop' into rgi…
Robbie-Microsoft Oct 23, 2026
d2b9ab2
Align FIC Bearer and PoP result binding
Robbie-Microsoft Oct 23, 2026
8b703d7
Merge PR #938 into FIC mTLS follow-up
Robbie-Microsoft Oct 23, 2026
b70842b
Address FIC mTLS review feedback
Robbie-Microsoft Oct 23, 2026
84ff3e8
Align FIC E2E cells with shared SDK lab setup
Robbie-Microsoft Oct 23, 2026
eeb27d0
Merge required certificate E2E coverage from #938
Robbie-Microsoft Oct 23, 2026
51cd6fd
Merge final shared certificate E2E correction from #938
Robbie-Microsoft Oct 23, 2026
3a9c636
Restore default FIC E2E wire and binding guarantees
Robbie-Microsoft Oct 27, 2026
cd295d4
Document targeted offline FIC guard invocation
Robbie-Microsoft Oct 27, 2026
70d4137
Merge shared certificate E2E core into default FIC coverage
Robbie-Microsoft Oct 27, 2026
f7b306d
Merge shared default-transport cleanup correction from #938
Robbie-Microsoft Oct 27, 2026
321d9f6
Merge final portable cleanup guard from #938
Robbie-Microsoft Oct 27, 2026
d61ce7c
Merge shared transport-failure privacy fix from #938
Robbie-Microsoft Oct 27, 2026
bb9c101
Merge final shared recorder privacy and cache removal fixes
Robbie-Microsoft Oct 27, 2026
0611fbd
Preserve actual certificate binding across FIC legs
Robbie-Microsoft Nov 2, 2026
bf763f9
Snapshot nested FIC certificate credential dictionaries
Robbie-Microsoft Nov 2, 2026
59dfd90
Merge shared mTLS snapshot and opaque Graph contracts
Robbie-Microsoft Nov 2, 2026
6756266
Merge final PFX snapshot compatibility correction
Robbie-Microsoft Nov 2, 2026
ed9c608
Merge approved cache identity retirement correction
Robbie-Microsoft Nov 2, 2026
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
18 changes: 18 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,24 @@ 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 containing the leg-1 result's
``binding_certificate``. This reuses the actual certificate snapshot rather
than reopening a PFX path that may have changed between the two legs. The
leg-1 assertion is then sent as a ``jwt-pop`` client assertion over mTLS
using that same certificate. A certificate credential dictionary remains
supported when the caller already guarantees certificate continuity.
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
233 changes: 188 additions & 45 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
103 changes: 103 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,19 @@
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. Leg 2 reuses the actual certificate binding returned
with the leg-1 assertion, not a second load of the PFX path. Each application
keeps its certificate snapshot; replacing the file does not rotate those apps.

Then run::

python confidential_client_mtls_pop_sample.py
Expand Down Expand Up @@ -110,6 +126,93 @@ 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 acquire_leg1():
# 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 not leg1.get("access_token"):
_print_result_summary(leg1, "FIC leg 1 result:")
raise RuntimeError("FIC leg 1 failed; inspect the result to diagnose.")
if (leg1.get("token_type") != "mtls_pop" or not isinstance(
leg1.get("binding_certificate"), msal.MtlsBindingContext)):
raise RuntimeError("FIC leg 1 must return an mTLS PoP certificate binding.")
return leg1

first_leg1 = acquire_leg1()
binding = first_leg1["binding_certificate"]

def get_leg1_assertion():
leg1 = acquire_leg1()
current_binding = leg1["binding_certificate"]
if (current_binding.thumbprint_sha256 != binding.thumbprint_sha256
or current_binding.x5c != binding.x5c):
raise RuntimeError(
"FIC leg 1 certificate binding changed; recreate both applications.")
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": binding,
},
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()
78 changes: 78 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,84 @@ 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.

Both default-transport cells require real token POST evidence for each leg,
the expected global or `westus3` mTLS endpoint, and the same lab certificate.
Leg 2 must invoke the assertion provider and send its exact, unchanged leg-1
value as a `jwt-pop` client assertion. The exchange assertion may be opaque;
it is not assumed to be a resource JWT. Leg 2 receives the actual
`MtlsBindingContext` returned with the leg-1 assertion rather than reloading
the PFX path. The sample also checks binding continuity when the callable
renews the assertion. Final Storage Bearer tokens must have the Storage
audience. Graph owns its token format: the final Graph token is treated as
opaque and requires matching returned certificate binding plus HTTP 200 from
the mTLS Graph resource using that context, not client-side JWT inspection.
The same token must also fail the shared no-certificate, wrong-certificate,
and Bearer-scheme controls with their exact rejection markers.

The required FIC inventory is exactly these two methods in
`tests.test_e2e.FicTwoLegTestCase`:

- `test_credential_fic_output_bearer`
- `test_credential_fic_output_pop`

They run without skips, including on tagged releases. Custom HTTP-factory
acquisitions are not part of this default-FIC branch.
This branch's required live inventory totals eight methods: the six inherited
`CertificateScenariosTestCase` cells documented below and these two FIC cells.
`tests\test_lab_config.py` checks the exact method sets and proves that all
eight produce credential errors, not skips, when the certificate is missing.

The FIC mapping reuses the already pinned .NET
`c8b90e9de5bc4974de9928835532faac5c88e2d2` [client-credentials cases][dotnet-cc]
and Go `30f4770ffcbd9c4c51d278cdab6dbda71a0f7337`
[two-leg integration cases][go-fic]:

| Active reference case(s) | Python FIC method | Required observation |
| --- | --- | --- |
| Go `TestTwoLegFICMtlsPoP_SNI`; .NET `Credential_Fic_Output_Pop_TestAsync` / `RunTwoLegS2sFicBothLegsPopAsync` | `test_credential_fic_output_pop` | Global exchange to Graph; invoked provider, unchanged `jwt-pop` assertion, actual returned-binding handoff, final PoP type/binding, authoritative resource HTTP 200. |
| Go `TestTwoLegFICMtlsBearer_SNI`; .NET `Credential_Fic_Output_Bearer_TestAsync` | `test_credential_fic_output_bearer` | `westus3` exchange to Storage; provider and real `jwt-pop` POST, certificate continuity, Storage Bearer, no output binding or downstream resource mTLS. |

Run these two live cells with:

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

The offline `tests\test_fic_e2e_helpers.py` checks the two-leg provider/wire
contract, opaque exchange and Graph tokens, strict final binding/type and
Storage audience, Graph rejection failures, and exact assertion handoff.
`tests.test_application.TestFicCertificateHandoff` replaces a PFX with a second
certificate after caching the leg-1 assertion, checks both token and actual
TLS certificate/key continuity, and checks renewal and Bearer/PoP output
contracts without reopening that file. The recorder privacy regression uses
pytest's actual traceback renderer with arguments shown and locals disabled,
and rejects full tokens and truncated token fragments. These offline guards
are not evidence of live acquisition or a custom HTTP factory.

```powershell
python -m pytest -q tests\test_fic_e2e_helpers.py tests\test_lab_config.py tests\test_e2e.py::FicScenarioTestCase
```

[go-fic]: https://github.com/AzureAD/microsoft-authentication-library-for-go/blob/30f4770ffcbd9c4c51d278cdab6dbda71a0f7337/apps/tests/integration/mtls_pop_integration_test.go

### Exportable-certificate scope and deferred protected keys

This feature supports exportable PEM/PFX certificate credentials. A custom HTTP
Expand Down
Loading
Loading