Skip to content

Commit c593a70

Browse files
CopilotgladjohnCopilot
authored
Feature: mTLS ****** via CertificateOptions.SendCertificateOverMtls (#5849)
* initial * Address review comments: remove dead appConfig, contradictory SendX5C - Remove unused appConfig variable from both mTLS integration tests - Remove contradictory SendX5C=true from mTLS Bearer test (SendCertificateOverMtls bypasses JWT assertion) - Remove unnecessary SendX5C=true from PoP test (PoP overrides transport regardless) - Resolve rebase conflicts with main (MsalError docs, PublicAPI files) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix MsalError doc to cover all InvalidCredentialMaterial scenarios Restore mitigation text to include both WithCertificate() and ClientSignedAssertion callback paths for mTLS Proof-of-Possession. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: fix namespace refs and nullable assertion - Replace Client.AppConfig.CertificateOptions with properly imported CertificateOptions type (add using Microsoft.Identity.Client.AppConfig) - Fix nullable bool? in Assert.IsTrue by adding ?? false coalescing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address open review comments: clarify static-cert-only docs, error message, and add mTLS transport assertion - MsalErrorMessage: explicitly state static WithCertificate(X509Certificate2) overload requirement - CertificateOptions XML docs: clarify transport-only semantics (token type depends on request-level config) - CertificateOptions XML docs: say 'static certificate overload only' instead of generic 'certificate credentials' - Integration test: add mtlsauth endpoint assertion to verify mTLS transport path was taken Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address remaining review comments: add static-cert-only guard comment, improve cache test clarity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: extract ValidateCertificate, remove dead null check and unused CT param, revert CHANGELOG - CertificateAndClaimsClientCredential: extract shared ValidateCertificate() to eliminate duplication between ResolveCertificateForMtlsAsync and ResolveCertificateAsync - CertificateAndClaimsClientCredential: remove unused CancellationToken param from ResolveCertificateForMtlsAsync (CT is already in AssertionRequestOptions) - MtlsPopParametersInitializer: remove dead null check (ResolveCertificateForMtlsAsync already throws on null) - MtlsPopParametersInitializer: clarify dynamic cert support in comments - Revert CHANGELOG.md to match main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * comment --------- Co-authored-by: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent ead58c8 commit c593a70

17 files changed

Lines changed: 405 additions & 34 deletions

File tree

src/client/Microsoft.Identity.Client/ApiConfig/Parameters/MtlsPopParametersInitializer.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ internal static async Task TryInitAsync(
3535

3636
/// <summary>
3737
/// NON-PoP request:
38-
/// We may still need mTLS transport if the credential can return a TokenBindingCertificate.
38+
/// We may still need mTLS transport in two situations:
39+
/// Case 1 – The app-level SendCertificateOverMtls option is set and the credential is certificate-based
40+
/// (both static <see cref="CertificateClientCredential"/> and dynamic
41+
/// <see cref="DynamicCertificateClientCredential"/> are supported).
42+
/// Case 2 – The credential is a signed-assertion provider that returns a TokenBindingCertificate.
3943
/// </summary>
4044
private static async Task TryInitImplicitBearerOverMtlsAsync(
4145
AcquireTokenCommonParameters tokenParameters,
@@ -47,7 +51,20 @@ private static async Task TryInitImplicitBearerOverMtlsAsync(
4751
return;
4852
}
4953

50-
// Only cert-capable credentials implement this capability interface.
54+
// Case 1 – App opted into mTLS Bearer via SendCertificateOverMtls on a certificate-based credential.
55+
if (serviceBundle.Config.CertificateOptions?.SendCertificateOverMtls == true &&
56+
serviceBundle.Config.ClientCredential is CertificateAndClaimsClientCredential certBasedCred)
57+
{
58+
// Static credentials have Certificate set directly
59+
tokenParameters.MtlsCertificate = certBasedCred.Certificate
60+
?? await certBasedCred.ResolveCertificateForMtlsAsync(
61+
CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct))
62+
.ConfigureAwait(false);
63+
64+
return;
65+
}
66+
67+
// Case 2 – Only cert-capable credentials implement this capability interface.
5168
if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider)
5269
{
5370
var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct);

src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ public string ClientVersion
125125
public bool IsConfidentialClient { get; }
126126
public bool IsPublicClient => !IsConfidentialClient && !IsManagedIdentity;
127127
public string CertificateIdToAssociateWithToken { get; set; }
128+
public CertificateOptions CertificateOptions { get; internal set; }
128129

129130
public Func<AppTokenProviderParameters, Task<AppTokenProviderResult>> AppTokenProvider;
130131

src/client/Microsoft.Identity.Client/AppConfig/CertificateOptions.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,30 @@ public record CertificateOptions
2424
/// by default it is set to <see langword="false"/> /></remarks>
2525
/// </summary>
2626
public bool AssociateTokensWithCertificate { get; init; } = false;
27+
28+
/// <summary>
29+
/// Gets or sets a value indicating whether the certificate should be sent over mTLS
30+
/// (TLS client certificate authentication) as the default transport for token requests.
31+
/// When <see langword="true"/>, the certificate is sent in the TLS handshake instead of as a
32+
/// JWT assertion in the request body. This controls transport only — the resulting token type
33+
/// depends on request-level configuration: a plain request produces a Bearer token, while
34+
/// <see cref="AcquireTokenForClientParameterBuilder.WithMtlsProofOfPossession"/> produces
35+
/// an mTLS PoP token.
36+
/// When <see langword="false"/> (default), the certificate is sent as a JWT assertion in the request body.
37+
/// </summary>
38+
/// <remarks>
39+
/// <para>This property sets the default transport for requests that do not explicitly call
40+
/// <see cref="AcquireTokenForClientParameterBuilder.WithMtlsProofOfPossession"/>.</para>
41+
/// <para>Request-level <see cref="AcquireTokenForClientParameterBuilder.WithMtlsProofOfPossession"/>
42+
/// always implies mTLS transport, regardless of this setting.</para>
43+
/// <para>This option is supported with certificate-based credentials configured via
44+
/// <see cref="ConfidentialClientApplicationBuilder.WithCertificate(System.Security.Cryptography.X509Certificates.X509Certificate2, CertificateOptions)"/>
45+
/// (static certificate) or the dynamic certificate provider overload.
46+
/// Non-certificate credentials will throw at build time.</para>
47+
/// <para>When no Azure region is configured, requests are sent to the global mTLS endpoint.
48+
/// When <see cref="ConfidentialClientApplicationBuilder.WithAzureRegion(string)"/> is also set,
49+
/// requests are routed to the regional mTLS endpoint instead.</para>
50+
/// </remarks>
51+
public bool SendCertificateOverMtls { get; init; } = false;
2752
}
2853
}

src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ public ConfidentialClientApplicationBuilder WithCertificate(X509Certificate2 cer
163163

164164
Config.ClientCredential = new CertificateClientCredential(certificate);
165165
Config.SendX5C = certificateOptions?.SendX5C ?? false;
166-
166+
Config.CertificateOptions = certificateOptions;
167+
167168
return this;
168169
}
169170

@@ -476,6 +477,16 @@ internal override void Validate()
476477
}
477478

478479
ValidateAndUpdateRegion();
480+
481+
// SendCertificateOverMtls is only supported with certificate-based credentials
482+
// (both static WithCertificate(X509Certificate2, ...) and dynamic WithCertificate(Func<...>, ...)).
483+
if (Config.CertificateOptions?.SendCertificateOverMtls == true
484+
&& Config.ClientCredential is not CertificateAndClaimsClientCredential)
485+
{
486+
throw new MsalClientException(
487+
MsalError.InvalidCredentialMaterial,
488+
MsalErrorMessage.SendCertificateOverMtlsRequiresCertificate);
489+
}
479490
}
480491

481492
private void ValidateAndUpdateRegion()

src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public static ConfidentialClientApplicationBuilder WithCertificate(
7575
certificateProvider: certificateProvider);
7676

7777
builder.Config.SendX5C = certificateOptions?.SendX5C ?? false;
78+
builder.Config.CertificateOptions = certificateOptions;
7879

7980
return builder;
8081
}

src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ public async Task<CredentialMaterial> GetCredentialMaterialAsync(
9999
return new CredentialMaterial(parameters, certificate);
100100
}
101101

102+
/// <summary>
103+
/// Resolves the certificate for use as an mTLS transport credential, without building a full
104+
/// JWT client assertion. Invokes the provider delegate (which may be a static lambda or a
105+
/// true async callback) and validates the result.
106+
/// Called by <see cref="Microsoft.Identity.Client.ApiConfig.Parameters.MtlsPopParametersInitializer"/>
107+
/// for the implicit Bearer-over-mTLS path when
108+
/// <see cref="AppConfig.CertificateOptions.SendCertificateOverMtls"/> is <see langword="true"/>.
109+
/// </summary>
110+
internal async Task<X509Certificate2> ResolveCertificateForMtlsAsync(
111+
AssertionRequestOptions options)
112+
{
113+
X509Certificate2 certificate = await _certificateProvider(options).ConfigureAwait(false);
114+
115+
ValidateCertificate(certificate);
116+
117+
return certificate;
118+
}
119+
102120
/// <summary>
103121
/// Resolves the certificate to use for signing the client assertion.
104122
/// Invokes the certificate provider delegate to get the certificate.
@@ -122,11 +140,22 @@ private async Task<X509Certificate2> ResolveCertificateAsync(
122140
// Invoke the provider to get the certificate
123141
X509Certificate2 certificate = await _certificateProvider(options).ConfigureAwait(false);
124142

125-
// Validate the certificate returned by the provider
143+
ValidateCertificate(certificate);
144+
145+
context.Logger.Verbose(
146+
() => $"[CertificateAndClaimsClientCredential] Certificate resolved. " +
147+
$"Thumbprint: {certificate.Thumbprint}");
148+
149+
return certificate;
150+
}
151+
152+
/// <summary>
153+
/// Validates that the certificate is non-null and has a private key.
154+
/// </summary>
155+
private static void ValidateCertificate(X509Certificate2 certificate)
156+
{
126157
if (certificate == null)
127158
{
128-
context.Logger.Error("[CertificateAndClaimsClientCredential] Certificate provider returned null.");
129-
130159
throw new MsalClientException(
131160
MsalError.InvalidClientAssertion,
132161
"The certificate provider callback returned null. Ensure the callback returns a valid X509Certificate2 instance.");
@@ -136,28 +165,18 @@ private async Task<X509Certificate2> ResolveCertificateAsync(
136165
{
137166
if (!certificate.HasPrivateKey)
138167
{
139-
context.Logger.Error("[CertificateAndClaimsClientCredential] The certificate does not have a private key.");
140-
141168
throw new MsalClientException(
142169
MsalError.CertWithoutPrivateKey,
143170
MsalErrorMessage.CertMustHavePrivateKey(certificate.FriendlyName));
144171
}
145172
}
146173
catch (System.Security.Cryptography.CryptographicException ex)
147174
{
148-
context.Logger.Error("[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate.");
149-
150175
throw new MsalClientException(
151176
MsalError.CryptographicError,
152177
MsalErrorMessage.CryptographicError,
153178
ex);
154179
}
155-
156-
context.Logger.Verbose(
157-
() => $"[CertificateAndClaimsClientCredential] Certificate resolved. " +
158-
$"Thumbprint: {certificate.Thumbprint}");
159-
160-
return certificate;
161180
}
162181
}
163182
}

src/client/Microsoft.Identity.Client/MsalError.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,10 +1259,11 @@ public static class MsalError
12591259
/// <summary>
12601260
/// <para>What happened?</para> The configured credential type is not compatible with the
12611261
/// requested authentication mode. For example, a client secret cannot be used with mTLS
1262-
/// Proof-of-Possession because mTLS requires a certificate to bind the token to the TLS transport.
1263-
/// <para>Mitigation:</para> Use a certificate-based credential or a delegate that returns a
1264-
/// <see cref="ClientSignedAssertion"/> with a <see cref="ClientSignedAssertion.TokenBindingCertificate"/>
1265-
/// when mTLS Proof-of-Possession is required.
1262+
/// Proof-of-Possession or <c>SendCertificateOverMtls</c> because mTLS requires a certificate
1263+
/// to bind the token to the TLS transport.
1264+
/// <para>Mitigation:</para> Use a certificate-based credential via <c>WithCertificate()</c>,
1265+
/// or a delegate that returns a <see cref="ClientSignedAssertion"/> with a
1266+
/// <see cref="ClientSignedAssertion.TokenBindingCertificate"/> when mTLS Proof-of-Possession is required.
12661267
/// </summary>
12671268
public const string InvalidCredentialMaterial = "invalid_credential_material";
12681269
}

src/client/Microsoft.Identity.Client/MsalErrorMessage.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,5 +455,10 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName)
455455
public const string CannotSwitchBetweenImdsVersionsForPreview = "ImdsV2 is currently experimental - A Bearer token has already been received; Please restart the application to receive a mTLS PoP token.";
456456
public const string MtlsPopTokenNotSupportedinImdsV1 = "mTLS Proof of Possession with managed identity is currently in private preview and is not supported on this VM. Ensure you're running on a supported VM image.";
457457
public const string ManagedIdentityAllSourcesUnavailable = "All Managed Identity sources are unavailable.";
458+
public const string SendCertificateOverMtlsRequiresCertificate =
459+
"CertificateOptions.SendCertificateOverMtls is only valid with a certificate-based credential " +
460+
"configured via WithCertificate(). Non-certificate credentials (client secrets, static signed " +
461+
"assertions, and string-returning assertion delegates) are not supported. " +
462+
"Remove SendCertificateOverMtls or switch to a certificate credential.";
458463
}
459464
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.get -> System.Guid
2-
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
31
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
2+
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
3+
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
4+
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.get -> System.Guid
5+
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.get -> System.Guid
2-
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
31
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
2+
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
3+
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
4+
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.get -> System.Guid
5+
Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void

0 commit comments

Comments
 (0)