Skip to content

Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential)#5957

Merged
gladjohn merged 8 commits into
mainfrom
gladjohn/DynamicCertificateClientCredential
Jun 2, 2026
Merged

Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential)#5957
gladjohn merged 8 commits into
mainfrom
gladjohn/DynamicCertificateClientCredential

Conversation

@gladjohn

@gladjohn gladjohn commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds mTLS Proof-of-Possession support for the dynamic certificate credential (WithCertificate(() => x509)). Previously, calling .WithMtlsProofOfPossession() with a dynamic certificate provider threw MtlsCertificateNotProvided at preflight because MtlsPopParametersInitializer didn't handle DynamicCertificateClientCredential.

The provider is invoked exactly once per mTLS token request.

Fixes #5943

How the single invocation works

  1. Preflight (MtlsPopParametersInitializer.InitExplicitMtlsPopAsync) invokes the credential's GetCredentialMaterialAsync(Mode == Mtls) polymorphically (via ResolveMtlsMaterialAsync) to resolve the certificate, builds the cnf claim, validates AAD region/tenant constraints, and stashes the resolved cert on p.MtlsCertificate.
  2. Credential material resolution (CredentialMaterialResolver.ResolveAsync) sees that requestParams.MtlsCertificate is set and the credential is a CertificateAndClaimsClientCredential, and short-circuits to (empty params, cert) without re-invoking the credential — avoiding a second provider delegate call at runtime.

The implicit Bearer-over-mTLS path (SendCertificateOverMtls = true) goes through the same polymorphic resolve and benefits from the same single-invocation guarantee.

Changes

MtlsPopParametersInitializer.cs

  • InitExplicitMtlsPopAsync resolves the cert polymorphically via ResolveMtlsMaterialAsync (no concrete-credential downcasts) and translates MsalError.InvalidCredentialMaterial to MtlsCertificateNotProvided to preserve the public mTLS PoP error-code contract.
  • TryInitImplicitBearerOverMtlsAsync (SendCertificateOverMtls = true path) uses the same polymorphic resolver. The Case 1 comment now correctly describes the contract: ConfidentialClientApplicationBuilder.Validate already rejects the non-cert pairing at construction time, so the polymorphic resolve is guaranteed to succeed.
  • BuildPreflightContext extracted as a single chokepoint for CredentialContext.Create(...), reused across the preflight paths.
  • Guarded TenantId = AuthorityInfo.GetFirstPathSegment(...) behind AuthorityType == Aad so non-AAD authorities (ADFS, DSTS, Generic, MI) no longer throw InvalidOperationException at preflight; TenantId is left null and runtime resolution fills it in.
  • <remarks> on InitExplicitMtlsPopAsync now covers all four GetCredentialMaterialAsync(Mtls) outcomes, including the WithClientClaims direct-throw path.

CertificateAndClaimsClientCredential.cs

  • GetCredentialMaterialAsync(Mode == Mtls) returns (empty params, cert) from a single provider invocation. The comment above the cert resolution call spells out the invariant for subclass authors: mTLS-mode output must remain (empty, cert), because CredentialMaterialResolver.ResolveAsync short-circuits this method at runtime when a preflight cert is present — overriding this method alone will be silently bypassed.
  • _claimsToSign is not null guard inside the mTLS branch (existing WithClientClaims rejection) keeps the MsalError.MtlsCertificateNotProvided error code and now throws with MsalErrorMessage.MtlsNotSupportedWithClientClaimsMessage. The guard fires on every mTLS-mode resolution path — explicit PoP and implicit Bearer-over-mTLS (SendCertificateOverMtls = true) — so the message names both transports rather than only Proof-of-Possession.

CredentialMaterialResolver.cs

  • ResolveAsync short-circuits to (empty, requestParams.MtlsCertificate) when the credential is a CertificateAndClaimsClientCredential and the preflight has already resolved a cert. The block is flanked by a documented invariant: subclasses that need different mTLS-mode behaviour must change this short-circuit too — overriding the method alone will be silently bypassed.

MsalErrorMessage.cs

  • New constant MtlsNotSupportedWithClientClaimsMessage describing the WithClientClaims + mTLS conflict for both PoP and Bearer-over-mTLS transports.

MtlsPopTests.cs

  • MtlsPop_WithDynamicCertificate_WithoutRegion_UsesGlobalMtlsEndpointAsync — dynamic cert + mTLS PoP without region falls through to the global mTLS endpoint, matching the static-cert behavior validated by MtlsPop_WithoutRegion_UsesGlobalMtlsEndpoint.
  • MtlsPop_WithDynamicCertificate_NullFromProvider_ThrowsAsync — null from provider throws MtlsCertificateNotProvided.
  • MtlsPop_WithDynamicCertificate_SuccessAsync — end-to-end success with Assert.AreEqual(1, providerCallCount, ...) locking in the single-invocation invariant. If the resolver short-circuit is ever broken, this test fails with a self-describing message.
  • MtlsPopWithoutCertificateWithClientClaimsAsync — tightened with StringAssert.Contains(ex.Message, "WithClientClaims") so a future error-message refactor cannot silently revert the WithClientClaims diagnostic.
  • SendCertificateOverMtls_WithClientClaims_ThrowsClearMessageAsyncnew regression test covering the previously-uncovered SendCertificateOverMtls = true + WithClientClaims combo (no explicit .WithMtlsProofOfPossession()). Asserts the message names both transports and the WithClientClaims API.

Design decisions

  • Guard lives in CertificateAndClaimsClientCredential.GetCredentialMaterialAsync, the common base for both static (CertificateClientCredential) and dynamic (DynamicCertificateClientCredential) certificate credentials. Subclasses pass claimsToSign: null, so the guard only fires for the WithClientClaims(cert, claims) configuration.
  • Single provider invocation is enforced by test, not convention. The providerCallCount == 1 assertion in MtlsPop_WithDynamicCertificate_SuccessAsync is the contract — any regression that re-introduces a second provider call fails this test loudly.
  • Single-invocation contract: Option 2 (resolver short-circuit + documented invariant) rather than Option 1 (explicit CredentialContext.PreResolvedCertificate field). Option 2 is the smaller change and the invariant is enforced by test plus comments on both sides; Option 1 would add a context field used by exactly one credential type.
  • No new public API surface. No PublicAPI.Unshipped.txt updates required.

Tests

  • Build: 0 warnings, 0 errors across netstandard2.0, net472, net462, net8.0.
  • Full unit test suite (Microsoft.Identity.Test.Unit, net8.0): 2119/2119 passing, 19 skipped (unrelated platform-gated).

Copilot AI review requested due to automatic review settings April 28, 2026 02:18

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 mTLS Proof-of-Possession (PoP) support for dynamic certificate credentials (WithCertificate(() => x509)) by wiring dynamic cert resolution into the mTLS PoP preflight path and extending unit coverage for these scenarios.

Changes:

  • Extend MtlsPopParametersInitializer to handle DynamicCertificateClientCredential during explicit mTLS PoP initialization.
  • Refactor certificate-provider validation in CertificateAndClaimsClientCredential and add a preflight resolution helper.
  • Add unit tests covering dynamic certificate + mTLS PoP success and failure cases.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs Adds unit tests for dynamic certificate + mTLS PoP (region missing, null provider result, success path).
src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs Extracts shared certificate validation and introduces a preflight certificate resolution method.
src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs Adds dynamic cert handling for explicit mTLS PoP preflight and expands callback options (Authority/TenantId).

Comment thread tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs
@bgavrilMS

Copy link
Copy Markdown
Member

When will this go in @gladjohn ?

@gladjohn gladjohn changed the title [DRAFT] Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential) Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential) May 12, 2026
@gladjohn

Copy link
Copy Markdown
Contributor Author

When will this go in @gladjohn ?

@bgavrilMS - ready for review. Saw you closed #5886 as not_planned, so I pivoted this PR to honor the #5943 principle directly instead of deferring: the certificate provider is now invoked exactly once per mTLS PoP token request.

Copilot AI review requested due to automatic review settings May 12, 2026 03:42
@gladjohn gladjohn force-pushed the gladjohn/DynamicCertificateClientCredential branch from d5ffe12 to 6e5b571 Compare May 12, 2026 03:42
@gladjohn gladjohn marked this pull request as ready for review May 12, 2026 03:44
@gladjohn gladjohn requested a review from a team as a code owner May 12, 2026 03:44

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 5 out of 5 changed files in this pull request and generated no new comments.

@gladjohn

Copy link
Copy Markdown
Contributor Author

@bgavrilMS @neha-bhargava would appreciate some reviews and feedback on this one

@bgavrilMS

Copy link
Copy Markdown
Member

@Robbie-Microsoft - pls help review this.

@bgavrilMS bgavrilMS left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Try to solve so that no upcasting is needed.

gladjohn added a commit that referenced this pull request May 14, 2026
Addresses review feedback on #5957: replaces the `is X || is Y` downcast chain in MtlsPopParametersInitializer.InitExplicitMtlsPopAsync with a single polymorphic call through the existing IClientCredential.GetCredentialMaterialAsync(Mode=Mtls) abstraction introduced in #5835.

- MtlsPopParametersInitializer.cs: InitExplicitMtlsPopAsync no longer downcasts to concrete credential types. All credentials are resolved through one helper, ResolveMtlsMaterialAsync, which builds a preflight CredentialContext with Mode=Mtls. Internal InvalidCredentialMaterial errors are translated to the public MtlsCertificateNotProvided code to preserve the existing public mTLS PoP API contract. InitMtlsPopParametersAsync unchanged, preserving the IAuthenticationOperation3.AfterCredentialEvaluationAsync lifecycle from #5996.

- CertificateAndClaimsClientCredential.cs: adds a _claimsToSign != null guard in the Mtls branch (preserves WithClientClaims + WithMtlsProofOfPossession rejection). ResolveCertificateAsync is now mode-aware on null cert. Drops the now-dead ResolveCertificateForMtlsAsync helper.

- CredentialMatrixTests.cs: null-cert assertions split per mode; WithClaims test renamed to assert MtlsCertificateNotProvided throw.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 14, 2026 16:12
@gladjohn gladjohn force-pushed the gladjohn/DynamicCertificateClientCredential branch from cda2add to 1ad61de Compare May 14, 2026 16:12

@Robbie-Microsoft Robbie-Microsoft 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.

Thorough review pass — most of this PR is in good shape, but there are a few items worth tightening before merge. Pasting them inline plus one PR-description issue here:

1) PR description does not match the implementation.

The "Changes / CredentialContext.cs" bullet says:

Added PreResolvedCertificate init-only property. When set and Mode == Mtls, credential implementations MUST reuse this certificate instead of invoking their provider delegate again.

This property does not exist in CredentialContext.cs in this PR (zero references in the repo). The actual mechanism is the resolver short-circuiting on requestParams.MtlsCertificate directly in CredentialMaterialResolver.ResolveAsync — see inline comment there. The credential layer never sees the pre-resolved cert; only the resolver does.

Similarly the "Design decisions" bullet says:

Matches DynamicCertificateClientCredential specifically, not the base class CertificateAndClaimsClientCredential, to avoid unintentionally changing behavior for WithClientClaims(...).

…but the new _claimsToSign is not null guard is in fact in the base class CertificateAndClaimsClientCredential.GetCredentialMaterialAsync. Public-API behavior for WithClientClaims(cert, claims) + WithMtlsProofOfPossession() is preserved (still throws MtlsCertificateNotProvided, as locked in by MtlsPopWithoutCertificateWithClientClaimsAsync), so this isn't a functional regression — but the description should be rewritten to describe what was actually built. Reviewers and release-note authors will otherwise miss the actual contract.

Nothing here blocks merging on functionality grounds — public-API behavior is preserved end-to-end and the new dynamic-cert tests are good. The four items below are the cleanups I'd want before this lands.

gladjohn added a commit that referenced this pull request Jun 1, 2026
…aims error message

- CredentialMaterialResolver / CertificateAndClaimsClientCredential:
  Document the single-invocation invariant (issue #5943) at both the
  resolver short-circuit site and inside the credential, so future
  CertificateAndClaimsClientCredential subclasses understand they must
  keep mTLS-mode output equal to (empty, cert) — overriding the method
  alone will be silently bypassed by the resolver at runtime.
  Addresses Robbie's review comment #1 (Option 2).

- MtlsPopParametersInitializer.cs: Correct the Case 1 comment in
  TryInitImplicitBearerOverMtlsAsync — the polymorphic resolve is
  reachable only because ConfidentialClientApplicationBuilder.Validate
  rejects the non-cert pairing at construction time; there is no
  Bearer-path fallback. Addresses Robbie's review comment #2.

- CertificateAndClaimsClientCredential / MsalErrorMessage: When mTLS
  PoP is combined with WithClientClaims, throw the existing
  MsalError.MtlsCertificateNotProvided (unchanged code, preserves
  MtlsPopWithoutCertificateWithClientClaimsAsync) with a new
  MsalErrorMessage.MtlsPopNotSupportedWithClientClaimsMessage that
  describes the actual WithClientClaims + mTLS conflict instead of
  the misleading "callback returned null" string. Addresses Robbie's
  review comment #3 (Option 2).

Full local unit test suite: 2055/2055 passing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs
Fixes 5 follow-up review comments after fdff674 (squashed in rebase as
7af3ea0). One real diagnostic bug + one regression test + doc tightening:

* MsalErrorMessage.cs:
  - Renamed MtlsPopNotSupportedWithClientClaimsMessage to
    MtlsNotSupportedWithClientClaimsMessage and generalized the wording.
    The guard that uses this message fires for *every* mTLS-mode resolution
    path — explicit .WithMtlsProofOfPossession() AND implicit
    CertificateOptions.SendCertificateOverMtls = true. The old wording
    falsely blamed Proof-of-Possession even when the user only opted into
    Bearer-over-mTLS, producing a confusing diagnostic.

* CertificateAndClaimsClientCredential.cs:
  - Updated reference to the renamed constant.
  - Tightened the in-line comment above the guard to call out that the
    rejection fires for both explicit PoP and implicit Bearer-over-mTLS,
    so the message wording is intentional.

* MtlsPopParametersInitializer.cs:
  - Added the 4th sub-bullet to the <remarks> on InitExplicitMtlsPopAsync
    covering the WithClientClaims direct-throw path through
    CertificateAndClaimsClientCredential.GetCredentialMaterialAsync. The
    list now describes all four GetCredentialMaterialAsync(Mtls) outcomes:
    cert credentials, signed-assertion credentials, client-claims (direct
    throw), and unsupported credentials (translated via the catch).

* MtlsPopTests.cs:
  - Tightened MtlsPopWithoutCertificateWithClientClaimsAsync to assert
    StringAssert.Contains(ex.Message, "WithClientClaims"). A future
    error-message centralisation refactor cannot silently revert the
    diagnostic without failing this test.
  - Added SendCertificateOverMtls_WithClientClaims_ThrowsClearMessageAsync
    as a regression test for the previously-uncovered combo:
    WithCertificate(cert, CertificateOptions { SendCertificateOverMtls
    = true }) + WithClientClaims(cert, claims), *no*
    .WithMtlsProofOfPossession(). The new test asserts the message names
    both transports and the WithClientClaims API so the diagnostic stays
    actionable for the Bearer-over-mTLS path.

Build: 0 warnings, 0 errors, 4 TFMs.
Test (Microsoft.Identity.Test.Unit, net8.0): 2119/2119 passed,
19 skipped (platform-gated). Includes the new regression test and the
tightened existing test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@gladjohn gladjohn enabled auto-merge (squash) June 2, 2026 17:03
@gladjohn gladjohn merged commit a7a17c7 into main Jun 2, 2026
15 checks passed
@gladjohn gladjohn deleted the gladjohn/DynamicCertificateClientCredential branch June 2, 2026 19:21
Robbie-Microsoft added a commit that referenced this pull request Jun 3, 2026
…arer flow after merge

PR #5957 (DynamicCertificateClientCredential) added two changes that conflicted
with this PR's design intent:

1. Short-circuit in CredentialMaterialResolver.ResolveAsync that returns
   (empty, cert) whenever MtlsCertificate is set on a CertificateAndClaimsClientCredential.
2. Mode logic that selects Mtls when MtlsCertificate != null, even when
   IsMtlsPopRequested is false.

For SendCertificateOverMtls=true bearer flows we set MtlsCertificate during
preflight to enable mtlsauth endpoint routing, but we still require the
credential to emit a client_assertion JWT (with x5c) in the POST body so
ESTS can validate the assertion against the SNI-registered cert.

Both behaviors are now guarded:

- Short-circuit skipped when SendCertificateOverMtls=true
- BuildContext mode reverts to 'IsMtlsPopRequested ? Mtls : OAuth' so the
  credential produces an OAuth-mode assertion regardless of MtlsCertificate
  presence on the bearer path

The SendX5C auto-enable from commit 7568b36 is preserved.

All 2124 unit tests pass on net8.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Jun 5, 2026
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.

[Engineering task] Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential)

5 participants