Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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()
69 changes: 69 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,75 @@ 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. Final resource tokens must be JWTs
with the Graph or Storage audience. Final Graph PoP additionally requires a
matching SHA256 `cnf` and returned `MtlsBindingContext`, then HTTP 200 from the
mTLS Graph resource using that context.

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, certificate continuity, strict final audience/type/`cnf`, 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 assertions, strict final binding/audience, and exact
assertion handoff. Its privacy regression uses pytest's actual traceback
renderer with arguments shown and locals disabled, and rejects full tokens
and truncated token fragments. These mock-driven 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

## 4) Run unit/integration tests

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