Skip to content

Add FIC two-leg exchange over mTLS Proof-of-Possession (follow-up to #938) - #939

Open
Robbie-Microsoft wants to merge 5 commits into
rginsburg/sni-mtls-popfrom
rginsburg/sni-mtls-pop-fic
Open

Add FIC two-leg exchange over mTLS Proof-of-Possession (follow-up to #938)#939
Robbie-Microsoft wants to merge 5 commits into
rginsburg/sni-mtls-popfrom
rginsburg/sni-mtls-pop-fic

Conversation

@Robbie-Microsoft

@Robbie-Microsoft Robbie-Microsoft commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #938. Adds the optional two-leg Federated Identity Credential (FIC) exchange over mTLS PoP: the leg-2 confidential client presents the leg-1 (cert-bound mtls_pop) token as a jwt-pop client assertion over an mTLS connection bound by the same certificate, to obtain the final token.

Stacked on rginsburg/sni-mtls-pop (#938) — review/merge #938 first; GitHub auto-retargets this PR to dev once #938 merges.

Usage

# Leg 1: SN/I cert -> cert-bound mtls_pop token (see #938)
leg1 = leg1_app.acquire_token_for_client([exchange_scope], mtls_proof_of_possession=True)

# Leg 2: present the leg-1 token as a jwt-pop assertion over the same cert's mTLS connection
leg2_app = msal.ConfidentialClientApplication(
    client_id,
    authority="https://login.microsoftonline.com/<tenant-id>",
    client_credential={
        "client_assertion": leg1["access_token"],   # cert-bound leg-1 token
        "mtls_binding_certificate": {"private_key_pfx_path": "sni.pfx", "public_certificate": True},
    },
)
leg2 = leg2_app.acquire_token_for_client(scopes, mtls_proof_of_possession=True)

What changed (delta over #938)

  • msal/oauth2cli/oauth2.pyjwt-pop client-assertion-type constant.
  • msal/application.py — detect the mtls_binding_certificate credential sub-key (FIC leg 2); carry the leg-1 assertion as jwt-pop; route every leg-2 request over the mTLS endpoint (even a plain Bearer travels the cert-bound connection); reject a leg-2 app on non–client-credential / silent flows and reject a string assertion without a binding cert; FIC-aware error messages.
  • Tests — FIC leg-2 (PoP + Bearer-over-mTLS) and the fail-fast cases in test_application.py; a self-skipping E2E case in test_e2e.py. Docstrings + docs/index.rst FIC bullet; fic_two_leg_over_mtls_pop() sample.

Full unit suite passes (base + FIC); no regressions.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for the optional two-leg Federated Identity Credential (FIC) token exchange over mTLS Proof-of-Possession, where a leg-1 mtls_pop token is presented as a jwt-pop client assertion over an mTLS connection bound to the same certificate to obtain the final token.

Changes:

  • Introduces the jwt-pop client assertion type constant and uses it for FIC leg-2 requests.
  • Extends ConfidentialClientApplication to detect client_credential["mtls_binding_certificate"] (FIC leg 2) and route leg-2 traffic over the mTLS token endpoint (including implicit Bearer-over-mTLS).
  • Adds unit/E2E coverage, docs, and a gated sample for the two-leg flow.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
msal/oauth2cli/oauth2.py Adds jwt-pop client assertion type constant for OAuth2 client assertions.
msal/application.py Implements FIC leg-2 detection and mTLS routing; sets client_assertion_type to jwt-pop for leg-2.
tests/test_application.py Adds unit tests validating jwt-pop assertion type and Bearer-over-mTLS behavior for FIC leg-2.
tests/test_e2e.py Adds an E2E test for the two-leg FIC exchange over mTLS PoP (self-skipping when not configured).
docs/index.rst Documents the FIC leg-2 mtls_binding_certificate configuration and jwt-pop behavior.
sample/confidential_client_mtls_pop_sample.py Adds a gated sample function demonstrating the two-leg FIC flow over mTLS PoP.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread msal/application.py
Comment thread msal/application.py Outdated
@Robbie-Microsoft
Robbie-Microsoft force-pushed the rginsburg/sni-mtls-pop branch from 5339d9e to dd4f13a Compare July 7, 2026 22:31
@Robbie-Microsoft
Robbie-Microsoft force-pushed the rginsburg/sni-mtls-pop-fic branch 5 times, most recently from 772ef58 to 614acc3 Compare July 8, 2026 21:58
@Robbie-Microsoft
Robbie-Microsoft requested a review from Copilot July 8, 2026 22:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

msal/application.py:892

  • The new FIC leg-2 configuration requires passing a leg-1 access token as a string client_assertion, but the constructor currently emits a DeprecationWarning for any static string/bytes client_assertion. This makes the documented/expected FIC usage noisy (and can fail callers running with warnings-as-errors). Consider suppressing this warning when mtls_binding_certificate is present (FIC leg 2).
        # Warn if using a static string/bytes client_assertion (discouraged for long-running apps)
        if isinstance(client_credential, dict) and isinstance(
                client_credential.get("client_assertion"), (str, bytes)):
            warnings.warn(
                "Passing a static string/bytes 'client_assertion' is "

Robbie-Microsoft and others added 3 commits July 8, 2026 18:51
Stacked on top of the SN/I mTLS PoP base support. Enables a Federated
Identity Credential (FIC) two-leg exchange over mutual-TLS: a leg-1
cert-bound mtls_pop token is presented as a jwt-pop client assertion over
an mTLS connection bound by the same certificate to obtain the final token.

- Detect the client_credential mtls_binding_certificate sub-key (FIC leg 2)
  and carry the leg-1 assertion as a jwt-pop client assertion over mTLS
- Route every FIC leg-2 request over the mTLS endpoint, so even a Bearer
  token travels the cert-bound connection (implicit Bearer-over-mTLS)
- Add unit + E2E coverage, docs, and a two-leg sample

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A confidential client configured for a Federated Identity Credential (FIC)
leg-2 exchange (a client_assertion plus an mtls_binding_certificate) is only
meaningful through acquire_token_for_client, which presents the binding
certificate over mutual-TLS and carries the leg-1 assertion as jwt-pop. If such
an app is instead used with any other flow (authorization code, on-behalf-of,
refresh token, username/password, or user-FIC), MSAL would send that cert-bound
leg-1 assertion as an ordinary jwt-bearer over a non-mTLS connection. ESTS
rejects it, but with a confusing server error.

Add a small _reject_if_mtls_binding_cert guard and call it at the top of those
flows so the misuse fails fast with a clear ValueError that points the caller to
acquire_token_for_client. The guard is a no-op for every non-FIC-leg-2 app, so
public clients and ordinary confidential clients are unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The _reject_if_mtls_binding_cert guard already covered the six
non-client-credential acquisition flows, but not acquire_token_silent /
acquire_token_silent_with_error. With a shared token cache holding user
refresh tokens, a FIC leg-2 app (client_assertion + mtls_binding_certificate)
could silently redeem one of those RTs, putting the cert-bound leg-1
assertion on a non-mTLS jwt-bearer request -- which ESTS rejects with a
confusing invalid_client rather than a clear message.

Add the guard to both public silent entry points. Both call the private
_acquire_token_silent_with_error directly (not each other), and
acquire_token_for_client also uses that private helper, so the guard lives
on the two public wrappers -- never on the private method -- to keep the
legitimate mTLS client-credential flow working. No-op for normal apps.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Robbie-Microsoft
Robbie-Microsoft force-pushed the rginsburg/sni-mtls-pop-fic branch from 614acc3 to acd78ed Compare July 8, 2026 22:51
Robbie-Microsoft added a commit that referenced this pull request Jul 30, 2026
Per cross-SDK review: document that the FIC two-leg e2e (PR #939) reuses this
helper as-is, so it is not inlined later. Explains why it takes an explicit
cert_credential instead of reading self.app's cert.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Robbie-Microsoft and others added 2 commits July 30, 2026 16:44
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>
Bring the FIC branch current with base dev by merging the resync'd SNI branch
(rginsburg/sni-mtls-pop @ 4ec592f, which merged origin/dev), so #939 also
carries PRs #911, #937, #940, #942.

Single conflict in msal/application.py acquire_token_for_client(), resolved by
integrating BOTH sides: keep the FIC-aware mTLS condition
(mtls_proof_of_possession or self._mtls_is_fic_leg2) and its comment from this
branch, AND add dev's forwarded_client_claims request-setup block above it. The
private _acquire_token_for_client() auto-merged correctly, combining the
FIC/mtls client selection (is_mtls_pop or self._mtls_is_fic_leg2) with dev's
client_claims -> OAuth claims merge.

The FIC e2e test test_fic_two_leg_over_mtls_pop and its leg-1 skip-hatch are
left UNTOUCHED (de-hatch + resource-call port + Credential_Fic_Output_* naming
remain Task 2). Region split, x509 pop/bearer matrix, and downgrade fail-closed
are all preserved.

Verified: py_compile clean; e2e matrix (pop/bearer) + FIC test collect; 22
mtls_pop + forwarded_client_claims unit tests pass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants