Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
64e1769
feat: extend mTLS bearer transport to OBO, refresh_token, and auth_co…
Robbie-Microsoft May 14, 2026
c2451d9
fix: remove ClassCleanup disposal of shared CertHelper cert
Robbie-Microsoft May 14, 2026
f37ce63
test: address Copilot review comments
Robbie-Microsoft May 14, 2026
596654c
test: fix typo and clarify client_assertion_type comment
Robbie-Microsoft May 14, 2026
774a1df
test: address review feedback - real integration tests, remove Silent…
Robbie-Microsoft May 19, 2026
7617782
Add OboFlow_WithClientSecret_BaselineAsync to complete Bogdan's 2x2 t…
Robbie-Microsoft May 20, 2026
c86dacc
fix: Bug #1 and Bug #2 for mTLS bearer user flows
Robbie-Microsoft May 21, 2026
b7059f2
Merge branch 'main' of https://github.com/AzureAD/microsoft-authentic…
Robbie-Microsoft May 21, 2026
b855aa7
Guard Case 2 in TryInitImplicitBearerOverMtlsAsync behind SendCertifi…
Robbie-Microsoft May 21, 2026
c67b809
Replace WithClientSecret baseline with negative test; add auth_code x…
Robbie-Microsoft May 21, 2026
51c16c8
docs: remove internal ESTS team language from mtls-bearer-transport.md
Robbie-Microsoft May 21, 2026
2932f1f
fix(mtls): send client_assertion on all flows including S2S
Robbie-Microsoft May 22, 2026
90b2cef
fix: address Copilot review comments
Robbie-Microsoft May 22, 2026
1c10e46
fix: address Copilot review comments round 2
Robbie-Microsoft May 22, 2026
12b9984
fix: restore WithCachePartitionKey and WithReservedScopes APIs from main
Robbie-Microsoft May 22, 2026
3df65bd
fix: restore SendOfflineAccessScope property and PublicAPI entries fr…
Robbie-Microsoft May 22, 2026
dddbf3d
fix: restore SendOfflineAccessScope in TokenClient and Authentication…
Robbie-Microsoft May 22, 2026
e54af78
fix: address Copilot review comments round 3
Robbie-Microsoft May 22, 2026
26250d2
fix: remove SendCertificateOverMtls guard from Case 2 in TryInitImpli…
Robbie-Microsoft May 22, 2026
35cc594
docs: clarify Case 2 double-invocation pattern in MtlsPopParametersIn…
Robbie-Microsoft May 22, 2026
7568b36
fix: auto-enable SendX5C when SendCertificateOverMtls=true for SNI ap…
Robbie-Microsoft May 22, 2026
2b351fc
test: [Ignore] OboFlow and RefreshTokenFlow blocked integration tests…
Robbie-Microsoft May 22, 2026
7e9ab79
test: strengthen regional OBO test assertions in ExpectedPostData
Robbie-Microsoft May 22, 2026
64e1040
Merge branch 'main' into rginsburg/mtls_bearer_user_flows
Robbie-Microsoft May 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
115 changes: 115 additions & 0 deletions docs/mtls-bearer-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# mTLS Bearer Transport for Confidential Client Applications

## What is mTLS Bearer Transport?

By default, MSAL authenticates a confidential client app by signing a JWT (`client_assertion`) with its certificate and including that assertion in the POST body of every token request.

**mTLS bearer transport** is an alternative: the certificate is presented at the **TLS layer** during the handshake, and no `client_assertion` is sent in the request body. The token returned is still a standard Bearer token.

This is enabled by the `SendCertificateOverMtls = true` option. When set:
- Token requests are routed to `mtlsauth.microsoft.com` (or a regional mTLS endpoint when `WithAzureRegion` is also configured)
- `client_assertion` is **not** included in the POST body
- The TLS certificate authenticates the app

Comment thread
Robbie-Microsoft marked this conversation as resolved.
## AAD Prerequisite: App Enablement (Preview)

> ⚠️ **This feature is in preview. Your app must be enabled for mTLS client auth by Microsoft Entra before token requests will succeed.**
>
> There is no self-serve portal today. Without enablement, AAD returns `AADSTS51000: MtlsClientAuth is/are disabled`.

## How to Opt In

Two steps are required.

### Step 1 — Configure the credential

```csharp
var cca = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true })
.WithHttpClientFactory(new MyMtlsHttpClientFactory(cert)) // see Step 2
.Build();
```

`SendCertificateOverMtls` requires a certificate-based credential. Passing it with a client secret throws at `Build()` time.

### Step 2 — Implement `IMsalMtlsHttpClientFactory`

MSAL calls `GetHttpClient(X509Certificate2)` to obtain an `HttpClient` that presents the client certificate during the TLS handshake. You must provide an implementation via `WithHttpClientFactory`.
Comment on lines +37 to +39

```csharp
public class MyMtlsHttpClientFactory : IMsalMtlsHttpClientFactory
{
// Reuse HttpClient instances — do NOT create a new one per call (socket exhaustion).
private readonly HttpClient _mtlsClient;
private readonly HttpClient _plainClient;

public MyMtlsHttpClientFactory(X509Certificate2 cert)
{
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(cert);
_mtlsClient = new HttpClient(handler);
_plainClient = new HttpClient();
}

// Called for mTLS token requests (cert at TLS layer)
public HttpClient GetHttpClient(X509Certificate2 cert) => _mtlsClient;

// Called for non-mTLS requests (e.g., instance discovery)
Comment on lines +56 to +59
public HttpClient GetHttpClient() => _plainClient;
}
```

### Acquire a token

```csharp
// S2S (app-to-app)
AuthenticationResult result = await cca
.AcquireTokenForClient(scopes)
.ExecuteAsync();

// On-behalf-of
AuthenticationResult result = await cca
.AcquireTokenOnBehalfOf(scopes, new UserAssertion(userToken))
.ExecuteAsync();
```

The same `WithCertificate(cert, new CertificateOptions { SendCertificateOverMtls = true })` configuration applies to all supported flows — no per-call change is needed.

## Supported Flows

| Flow | MSAL API |
|------|----------|
| App-to-app (S2S / client credentials) | `AcquireTokenForClient` |
| On-behalf-of (OBO) | `AcquireTokenOnBehalfOf` |
| Silent / refresh-token redemption | `AcquireTokenSilent`, `AcquireTokenByRefreshToken` |
| Authorization code | `AcquireTokenByAuthorizationCode` |

## How to Verify It's Working

### Option 1 — Check the token endpoint

```csharp
AuthenticationResult result = await cca.AcquireTokenForClient(scopes).ExecuteAsync();

// Should contain "mtlsauth.microsoft.com", not "login.microsoftonline.com"
Console.WriteLine(result.AuthenticationResultMetadata.TokenEndpoint);
```

### Option 2 — Intercept the request (unit/integration tests)

Use a recording `IMsalMtlsHttpClientFactory` (see `RecordingMtlsHttpClientFactory` in `MtlsTransportUserFlowTests.cs`) to capture the outgoing request. Assert:
- URL contains `mtlsauth`
- Body does **not** contain `client_assertion`

## Known Limitations

- **Windows only** — the mTLS client certificate stack depends on `System.Net.Security` behavior that is not supported on Linux in the current test configuration.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Outdated
- **AAD-side enablement required (preview)** — there is no self-serve portal today; app enablement requires Microsoft Entra configuration.
- **Certificate credential required** — `SendCertificateOverMtls = true` is incompatible with client secrets and throws at `Build()` time.

## Related Docs

- [sni_mtls_bearer_token_design.md](sni_mtls_bearer_token_design.md) — internal design spec
- [mtlspop_architecture.md](mtlspop_architecture.md) — mTLS PoP architecture (distinct from Bearer transport)
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ public T WithExtraQueryParameters(IDictionary<string, (string Value, bool Includ
return this as T;
}


/// <summary>
/// Validates the parameters of the AcquireToken operation.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public async Task<AuthenticationResult> ExecuteAsync(
AcquireTokenSilentParameters silentParameters,
CancellationToken cancellationToken)
{
await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken)
.ConfigureAwait(false);

Comment thread
Robbie-Microsoft marked this conversation as resolved.
var requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken);

var requestParameters = await _clientApplicationBase.CreateRequestParametersAsync(
Expand All @@ -47,6 +50,9 @@ public async Task<AuthenticationResult> ExecuteAsync(
AcquireTokenByRefreshTokenParameters refreshTokenParameters,
CancellationToken cancellationToken)
{
await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken)
.ConfigureAwait(false);

var requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken);
if (commonParameters.Scopes == null || !commonParameters.Scopes.Any())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public async Task<AuthenticationResult> ExecuteAsync(
AcquireTokenByAuthorizationCodeParameters authorizationCodeParameters,
CancellationToken cancellationToken)
{
await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken)
.ConfigureAwait(false);
Comment thread
Robbie-Microsoft marked this conversation as resolved.

RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken);

AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync(
Expand Down Expand Up @@ -85,6 +88,9 @@ public async Task<AuthenticationResult> ExecuteAsync(
AcquireTokenOnBehalfOfParameters onBehalfOfParameters,
CancellationToken cancellationToken)
{
await commonParameters.TryInitMtlsPopParametersAsync(ServiceBundle, cancellationToken)
.ConfigureAwait(false);

RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken);

AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ internal class AcquireTokenCommonParameters
public X509Certificate2 MtlsCertificate { get; internal set; }
public List<string> AdditionalCacheParameters { get; set; }
public SortedList<string, Func<CancellationToken, Task<string>>> CacheKeyComponents { get; internal set; }
public bool? SendOfflineAccessScope { get; set; }
public string FmiPathSuffix { get; internal set; }
public string ClientAssertionFmiPath { get; internal set; }
public bool IsMtlsPopRequested { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ private static async Task TryInitImplicitBearerOverMtlsAsync(
}

// Case 2 – Only cert-capable credentials implement this capability interface.
if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider)
// Guarded by SendCertificateOverMtls == true (same as Case 1) to avoid invoking
// the assertion callback on every request for apps that don't opt into mTLS.
if (serviceBundle.Config.CertificateOptions?.SendCertificateOverMtls == true &&
serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider)
{
var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Identity.Client.Advanced
{
Expand Down Expand Up @@ -46,64 +44,9 @@ public static T WithExtraHttpHeaders<T>(
this AbstractAcquireTokenParameterBuilder<T> builder,
IDictionary<string, string> extraHttpHeaders)
where T : AbstractAcquireTokenParameterBuilder<T>
{
{
builder.CommonParameters.ExtraHttpHeaders = extraHttpHeaders;
return (T)builder;
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Comment thread
Robbie-Microsoft marked this conversation as resolved.

/// <summary>
/// Adds a key-value pair to the token cache key without sending it as a query parameter.
/// Use this to partition cached tokens (e.g., isolating short-lived sessions from regular
/// sessions for the same user). Both <c>AcquireTokenByAuthorizationCode</c> and
/// <c>AcquireTokenSilent</c> must use the same partition key to match cached entries.
/// </summary>
/// <param name="builder">The builder to chain .With methods.</param>
/// <param name="key">The partition key name.</param>
/// <param name="value">The partition key value.</param>
/// <returns>The builder to chain .With methods.</returns>
public static T WithCachePartitionKey<T>(
this BaseAbstractAcquireTokenParameterBuilder<T> builder,
string key,
string value)
where T : BaseAbstractAcquireTokenParameterBuilder<T>
{
if (key is null)
{
throw new ArgumentNullException(nameof(key));
}

if (key.Length == 0)
{
throw new ArgumentException("Value cannot be empty.", nameof(key));
}

if (value is null)
{
throw new ArgumentNullException(nameof(value));
}

builder.CommonParameters.CacheKeyComponents ??= new SortedList<string, Func<CancellationToken, Task<string>>>();
string capturedValue = value;
builder.CommonParameters.CacheKeyComponents[key] = (CancellationToken _) => Task.FromResult(capturedValue);
return (T)builder;
}

/// <summary>
/// Controls whether MSAL sends the reserved <c>offline_access</c> scope while continuing to
/// send <c>openid</c> and <c>profile</c>. Only applicable to authorization code redemption flows.
/// </summary>
/// <param name="builder">The builder to chain .With methods.</param>
/// <param name="offlineAccessScope">
/// Set to <see langword="false"/> to omit <c>offline_access</c>. Set to <see langword="true"/>
/// to preserve the default MSAL behavior of sending all reserved scopes.
/// </param>
/// <returns>The builder to chain .With methods.</returns>
public static AcquireTokenByAuthorizationCodeParameterBuilder WithReservedScopes(
this AcquireTokenByAuthorizationCodeParameterBuilder builder,
bool offlineAccessScope)
{
builder.CommonParameters.SendOfflineAccessScope = offlineAccessScope;
return builder;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,15 @@ public async Task<InstanceDiscoveryMetadataEntry> GetMetadataAsync(Uri authority
string region = null;
bool isMtlsEnabled = requestContext.IsMtlsRequested;

if (requestContext.ApiEvent?.ApiId == TelemetryCore.Internal.Events.ApiEvent.ApiIds.AcquireTokenForClient)
// Always attempt region discovery for AcquireTokenForClient.
// Also attempt it for mTLS-enabled user flows when the app has opted in to
// regional endpoints (AzureRegion != null), so that OBO/RT can use a regional
// mTLS endpoint (e.g. eastus.mtlsauth.microsoft.com) when configured.
bool shouldAttemptRegionDiscovery =
requestContext.ApiEvent?.ApiId == TelemetryCore.Internal.Events.ApiEvent.ApiIds.AcquireTokenForClient ||
(isMtlsEnabled && requestContext.ServiceBundle.Config.AzureRegion != null);

if (shouldAttemptRegionDiscovery)
{
region = await _regionManager.GetAzureRegionAsync(requestContext).ConfigureAwait(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ private static CredentialContext BuildContext(
{
ClientId = requestParams.AppConfig.ClientId,
TokenEndpoint = tokenEndpoint,
Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested
Mode = requestParams.IsMtlsPopRequested
? CredentialTransportProtocol.Mtls
: CredentialTransportProtocol.OAuth,
Claims = requestParams.Claims,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ public AuthenticationRequestParameters(

HomeAccountId = homeAccountId;
CacheKeyComponents = cacheKeyComponents;
SendOfflineAccessScope = commonParameters.SendOfflineAccessScope;
}

public ApplicationConfiguration AppConfig => _serviceBundle.Config;
Expand Down Expand Up @@ -153,7 +152,6 @@ public IAuthenticationOperation AuthenticationScheme
public IEnumerable<string> PersistedCacheParameters => _commonParameters.AdditionalCacheParameters;

public SortedList<string, string> CacheKeyComponents {get; private set; }
public bool? SendOfflineAccessScope { get; private set; }

#region TODO REMOVE FROM HERE AND USE FROM SPECIFIC REQUEST PARAMETERS
// TODO: ideally, these can come from the particular request instance and not be in RequestBase since it's not valid for all requests.
Expand Down
6 changes: 0 additions & 6 deletions src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,6 @@ public async Task<MsalTokenResponse> SendTokenRequestAsync(

string scopes = !string.IsNullOrEmpty(scopeOverride) ? scopeOverride : GetDefaultScopes(_requestParams.Scope);

if (_requestParams.SendOfflineAccessScope is false)
{
scopes = string.Join(" ", scopes.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.Equals(s, OAuth2Value.ScopeOfflineAccess, StringComparison.OrdinalIgnoreCase)));
}

await AddBodyParamsAndHeadersAsync(additionalBodyParameters, scopes, tokenEndpoint, cancellationToken).ConfigureAwait(false);
Comment thread
Robbie-Microsoft marked this conversation as resolved.

Comment thread
Robbie-Microsoft marked this conversation as resolved.
AddThrottlingHeader();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value) -> T
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value) -> T
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value) -> T
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ Microsoft.Identity.Client.AuthScheme.IAuthenticationOperation3.AfterCredentialEv
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.CredentialEvaluationContext(System.Security.Cryptography.X509Certificates.X509Certificate2 mtlsCertificate) -> void
Microsoft.Identity.Client.AuthScheme.CredentialEvaluationContext.MtlsCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey<T>(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder<T> builder, string key, string value) -> T
static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithReservedScopes(this Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder builder, bool offlineAccessScope) -> Microsoft.Identity.Client.AcquireTokenByAuthorizationCodeParameterBuilder
Loading
Loading