Add mTLS PoP support for WithCertificate(() => x509) (DynamicCertificateClientCredential)#5957
Conversation
There was a problem hiding this comment.
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
MtlsPopParametersInitializerto handleDynamicCertificateClientCredentialduring explicit mTLS PoP initialization. - Refactor certificate-provider validation in
CertificateAndClaimsClientCredentialand 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). |
|
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. |
d5ffe12 to
6e5b571
Compare
|
@bgavrilMS @neha-bhargava would appreciate some reviews and feedback on this one |
|
@Robbie-Microsoft - pls help review this. |
bgavrilMS
left a comment
There was a problem hiding this comment.
Try to solve so that no upcasting is needed.
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>
cda2add to
1ad61de
Compare
Robbie-Microsoft
left a comment
There was a problem hiding this comment.
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
PreResolvedCertificateinit-only property. When set andMode == 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
DynamicCertificateClientCredentialspecifically, not the base classCertificateAndClaimsClientCredential, to avoid unintentionally changing behavior forWithClientClaims(...).
…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.
…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>
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>
…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>
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 threwMtlsCertificateNotProvidedat preflight becauseMtlsPopParametersInitializerdidn't handleDynamicCertificateClientCredential.The provider is invoked exactly once per mTLS token request.
Fixes #5943
How the single invocation works
MtlsPopParametersInitializer.InitExplicitMtlsPopAsync) invokes the credential'sGetCredentialMaterialAsync(Mode == Mtls)polymorphically (viaResolveMtlsMaterialAsync) to resolve the certificate, builds thecnfclaim, validates AAD region/tenant constraints, and stashes the resolved cert onp.MtlsCertificate.CredentialMaterialResolver.ResolveAsync) sees thatrequestParams.MtlsCertificateis set and the credential is aCertificateAndClaimsClientCredential, 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.csInitExplicitMtlsPopAsyncresolves the cert polymorphically viaResolveMtlsMaterialAsync(no concrete-credential downcasts) and translatesMsalError.InvalidCredentialMaterialtoMtlsCertificateNotProvidedto preserve the public mTLS PoP error-code contract.TryInitImplicitBearerOverMtlsAsync(SendCertificateOverMtls = truepath) uses the same polymorphic resolver. The Case 1 comment now correctly describes the contract:ConfidentialClientApplicationBuilder.Validatealready rejects the non-cert pairing at construction time, so the polymorphic resolve is guaranteed to succeed.BuildPreflightContextextracted as a single chokepoint forCredentialContext.Create(...), reused across the preflight paths.TenantId = AuthorityInfo.GetFirstPathSegment(...)behindAuthorityType == Aadso non-AAD authorities (ADFS, DSTS, Generic, MI) no longer throwInvalidOperationExceptionat preflight;TenantIdis left null and runtime resolution fills it in.<remarks>onInitExplicitMtlsPopAsyncnow covers all fourGetCredentialMaterialAsync(Mtls)outcomes, including the WithClientClaims direct-throw path.CertificateAndClaimsClientCredential.csGetCredentialMaterialAsync(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), becauseCredentialMaterialResolver.ResolveAsyncshort-circuits this method at runtime when a preflight cert is present — overriding this method alone will be silently bypassed._claimsToSign is not nullguard inside the mTLS branch (existingWithClientClaimsrejection) keeps theMsalError.MtlsCertificateNotProvidederror code and now throws withMsalErrorMessage.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.csResolveAsyncshort-circuits to(empty, requestParams.MtlsCertificate)when the credential is aCertificateAndClaimsClientCredentialand 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.csMtlsNotSupportedWithClientClaimsMessagedescribing theWithClientClaims+ mTLS conflict for both PoP and Bearer-over-mTLS transports.MtlsPopTests.csMtlsPop_WithDynamicCertificate_WithoutRegion_UsesGlobalMtlsEndpointAsync— dynamic cert + mTLS PoP without region falls through to the global mTLS endpoint, matching the static-cert behavior validated byMtlsPop_WithoutRegion_UsesGlobalMtlsEndpoint.MtlsPop_WithDynamicCertificate_NullFromProvider_ThrowsAsync— null from provider throwsMtlsCertificateNotProvided.MtlsPop_WithDynamicCertificate_SuccessAsync— end-to-end success withAssert.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 withStringAssert.Contains(ex.Message, "WithClientClaims")so a future error-message refactor cannot silently revert the WithClientClaims diagnostic.SendCertificateOverMtls_WithClientClaims_ThrowsClearMessageAsync— new regression test covering the previously-uncoveredSendCertificateOverMtls = true+WithClientClaimscombo (no explicit.WithMtlsProofOfPossession()). Asserts the message names both transports and theWithClientClaimsAPI.Design decisions
CertificateAndClaimsClientCredential.GetCredentialMaterialAsync, the common base for both static (CertificateClientCredential) and dynamic (DynamicCertificateClientCredential) certificate credentials. Subclasses passclaimsToSign: null, so the guard only fires for theWithClientClaims(cert, claims)configuration.providerCallCount == 1assertion inMtlsPop_WithDynamicCertificate_SuccessAsyncis the contract — any regression that re-introduces a second provider call fails this test loudly.CredentialContext.PreResolvedCertificatefield). 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.PublicAPI.Unshipped.txtupdates required.Tests
netstandard2.0,net472,net462,net8.0.Microsoft.Identity.Test.Unit, net8.0): 2119/2119 passing, 19 skipped (unrelated platform-gated).