Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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 a `client_assertion` JWT is also included in the POST 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** included in the POST body (ESTS requires it for this preview)
- The TLS certificate also authenticates the app at the transport layer

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`.

```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)
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 **contains** `client_assertion` (cert at TLS + assertion in body)

## Known Limitations

- **Integration test setup is Windows-only** — the provided integration tests use `[DoNotRunOnLinux]` due to test infrastructure constraints. The mTLS bearer transport feature itself works cross-platform wherever `HttpClientHandler` client certificate authentication is supported.
- **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 @@ -66,6 +66,18 @@ private static async Task TryInitImplicitBearerOverMtlsAsync(
}

// Case 2 – Only cert-capable credentials implement this capability interface.
// No SendCertificateOverMtls guard here: the TokenBindingCertificate pattern is a
// distinct opt-in where the assertion delegate itself signals mTLS intent by returning
// a non-null cert. This is separate from Case 1 (SendCertificateOverMtls + cert-based
// credential).
Comment thread
Robbie-Microsoft marked this conversation as resolved.
//
// Call pattern per request:
// - This call always fires (even cache hits) to check for TokenBindingCertificate
// and set MtlsCertificate for proper endpoint routing.
// - GetCredentialMaterialAsync (in ClientAssertionDelegateCredential) calls the
// delegate a second time on network requests to produce the signed assertion JWT.
// - Cache hits: delegate called once (here only). Network requests: twice.
// - Delegates are expected to be cheap (return a pre-generated/cached assertion).
if (serviceBundle.Config.ClientCredential is IClientSignedAssertionProvider signedProvider)
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
var opts = CreateAssertionRequestOptions(tokenParameters, serviceBundle, ct);
Expand Down
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,13 +61,16 @@ 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,
ClientCapabilities = requestParams.AppConfig.ClientCapabilities,
CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager,
SendX5C = requestParams.SendX5C,
// When SendCertificateOverMtls=true, the client_assertion JWT must include the x5c chain
// so that AAD can validate the assertion against the SNI-registered certificate.
SendX5C = requestParams.SendX5C
|| (requestParams.AppConfig.CertificateOptions?.SendCertificateOverMtls == true),
UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported,
ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims,
ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath,
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 @@ -119,6 +118,7 @@ public X509Certificate2 MtlsCertificate
}

public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested;
public bool? SendOfflineAccessScope => _commonParameters.SendOfflineAccessScope;

/// <summary>
/// The certificate resolved and used for client authentication (if certificate-based authentication was used).
Expand Down Expand Up @@ -153,7 +153,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
Original file line number Diff line number Diff line change
Expand Up @@ -722,8 +722,13 @@ private async Task<List<MsalAccessTokenCacheItem>> FilterTokensByEnvironmentAsyn
}

// at this point we need environment aliases, try to get them without a discovery call
// Use OriginalAuthority so that mTLS-transformed authorities (mtlsauth.microsoft.com) don't
// propagate into alias resolution. After ResolveAuthorityAsync, requestParams.AuthorityInfo
// may reflect the mtlsauth host; passing that to GetMetadataEntryTryAvoidNetworkAsync causes
// RegionAndMtlsDiscoveryProvider to throw MtlsPopNotSupportedForEnvironment. Using the
// original login.* authority ensures alias lookup always succeeds via instance discovery.
var instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync(
requestParams.AuthorityInfo,
requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo,
tokenCacheItems.Select(at => at.Environment), // if all environments are known, a network call can be avoided
requestParams.RequestContext)
.ConfigureAwait(false);
Expand Down Expand Up @@ -841,7 +846,7 @@ async Task<MsalRefreshTokenCacheItem> ITokenCacheInternal.FindRefreshTokenAsync(
{
var metadata =
await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync(
requestParams.AuthorityInfo,
requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo,
refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided
requestParams.RequestContext)
.ConfigureAwait(false);
Expand Down Expand Up @@ -871,7 +876,7 @@ await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsyn
{
var metadata =
await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync(
requestParams.AuthorityInfo,
requestParams.AuthorityManager.OriginalAuthority.AuthorityInfo,
refreshTokens.Select(rt => rt.Environment), // if all environments are known, a network call can be avoided
requestParams.RequestContext)
.ConfigureAwait(false);
Expand Down Expand Up @@ -1183,8 +1188,14 @@ private async Task<IDictionary<string, TenantProfile>> GetTenantProfilesAsync(
idTokenCacheItems.Select(aci => aci.Environment),
StringComparer.OrdinalIgnoreCase);

// Use OriginalAuthority for alias resolution so that mTLS-transformed authorities
// (mtlsauth.microsoft.com) don't propagate into the cache lookup.
// _currentAuthority may be set to the mTLS endpoint (PreferredNetwork) after instance
// discovery; using OriginalAuthority ensures we always look up aliases from the
// canonical login.* host, which is where id tokens are stored.
var authorityInfoForAliases = requestParameters.AuthorityManager.OriginalAuthority.AuthorityInfo;
InstanceDiscoveryMetadataEntry instanceMetadata = await ServiceBundle.InstanceDiscoveryManager.GetMetadataEntryTryAvoidNetworkAsync(
requestParameters.AuthorityInfo,
authorityInfoForAliases,
allEnvironmentsInCache,
requestParameters.RequestContext).ConfigureAwait(false);
Comment thread
Robbie-Microsoft marked this conversation as resolved.

Expand Down
Loading
Loading