Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
Unreleased
==========

### Changes
- Documented that mTLS Proof-of-Possession does **not** require a region: when no region is set, MSAL uses the cloud's global `mtlsauth` endpoint (`mtlsauth.microsoft.com` in public cloud; sovereign clouds use their own host).
- Added end-to-end and unit coverage for the two-leg S2S (app) Federated Identity Credential (FIC) flow over mTLS PoP (SNI first leg → federated assertion → resource token bound to the same certificate), including an FMI-audience variant. No public API change; `user_fic` is not supported over mTLS.

4.86.0
======

Expand Down
29 changes: 29 additions & 0 deletions docs/mtls_pop_skills_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ Which frameworks and platforms support mTLS PoP in MSAL.NET?

Use these for token exchange and two-leg flows:

> **Scope note:** FIC over mTLS PoP is supported for **S2S / app (client-credentials)** flows only.
> A user-delegated **`user_fic`** flow is **not** supported over mTLS.

#### SNI First Leg (S2S FIC)

```
How do I use an SNI certificate as the first leg of S2S FIC over mTLS PoP?
```

```
Show me SNI first leg -> S2S FIC -> mtls_pop end to end in MSAL.NET
```

```
How do I carry the Leg-1 BindingCertificate into Leg 2 with WithClientAssertion?
```

```
Is user_fic supported over mTLS PoP? (No - S2S/app FIC only)
```

```
What exchange audience do I use - api://AzureADTokenExchange vs api://AzureFMITokenExchange?
```

#### Understanding FIC Two-Leg Flow

```
Expand Down Expand Up @@ -170,6 +195,10 @@ What do I do if I get "MtlsCertificateNotProvided" error?
What does "MtlsPopWithoutRegion" error mean?
```

```
Is a region required for mTLS PoP? (No - it is optional; MSAL falls back to the cloud's global mtlsauth endpoint, e.g. mtlsauth.microsoft.com in public cloud)
```

```
How do I troubleshoot mTLS PoP token acquisition failures?
```
Expand Down
83 changes: 80 additions & 3 deletions docs/sni_mtls_pop_token_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ Bearer tokens are vulnerable to theft. Proof-of-Possession (PoP) tokens mitigate

### MTLS Endpoint Usage

The mTLS PoP flow relies on tenanted endpoints with support for regional configurations. The behavior varies based on the cloud environment:
The mTLS PoP flow relies on tenanted endpoints. A region is **optional** — it is recommended for performance, but when no region is configured (or auto-detection yields none) MSAL falls back to the **global** mTLS endpoint, which is production-ready. The behavior varies based on the cloud environment:

#### Region is optional (global fallback)

- **Regional (recommended):** `https://{region}.mtlsauth.microsoft.com/{tenant_id}` — set via `.WithAzureRegion(...)`.
- **Global (no region):** `https://mtlsauth.microsoft.com/{tenant_id}` (public cloud) — used automatically when no region is set. Sovereign clouds use their own `mtlsauth.<cloud-suffix>` host instead (e.g. `mtlsauth.microsoftonline.us`, `mtlsauth.partner.microsoftonline.cn`); the global host is derived from the authority by swapping the `login.` prefix for `mtlsauth`. There is **no "region required" error**; the earlier `RegionRequiredForMtlsPop` message is stale and retained only for API compatibility.
Comment thread
Robbie-Microsoft marked this conversation as resolved.

#### Public Cloud

Expand All @@ -52,7 +57,7 @@ The base endpoint for public clouds is:

Example: Specifying the `westus` region results in:

`https://eastus.mtlsauth.microsoft.com/{tenant_id}`
`https://westus.mtlsauth.microsoft.com/{tenant_id}`

#### Sovereign Clouds

Expand Down Expand Up @@ -97,6 +102,78 @@ AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithMtlsPr
.ExecuteAsync();
```

## Two-Leg S2S (App) FIC over mTLS PoP

A confidential-client app can use its SNI certificate as the **first leg** of a Federated Identity
Credential (FIC) exchange and obtain an **mTLS-bound PoP** token at the **second leg**. This reuses the
existing `WithClientAssertion(...)` seam that accepts a `ClientSignedAssertion` carrying an optional
`TokenBindingCertificate` — no new public API.

> **Scope:** FIC over mTLS PoP is supported for **S2S / app (client-credentials)** flows only.
> **`user_fic` (a user-delegated FIC) is _not_ supported over mTLS.**

- **Leg 1 — SNI cert → federated assertion (cert-bound PoP).** `AcquireTokenForClient` against the
exchange audience with `WithMtlsProofOfPossession()`. Leg 1 being cert-bound PoP is what produces the
`cnf`. The result exposes `AuthenticationResult.BindingCertificate` — the `X509Certificate2` the token
is bound to (surfaced in `cnf` as `x5t#S256`). It is the same cert used on the wire, so it may carry a
private key.
- **Leg 2 — federated assertion → final app token (bound to the Leg-1 cert).** `AcquireTokenForClient`
against the final resource, supplying the Leg-1 assertion **and** the Leg-1 binding certificate via
`ClientSignedAssertion.TokenBindingCertificate`, again with `WithMtlsProofOfPossession()`. MSAL sends
`client_assertion` + `client_assertion_type = urn:ietf:params:oauth:client-assertion-type:jwt-pop`
over mTLS, and the returned token is `mtls_pop`, bound to the **same** certificate as Leg 1.

```csharp
X509Certificate2 sniCert = GetCertificateFromStore("Use The Lab Auth Cert");

// Leg 1: SNI cert -> federated assertion (mtls_pop)
var leg1App = ConfidentialClientApplicationBuilder.Create(clientId)
.WithAuthority(authority)
.WithCertificate(sniCert, sendX5C: true)
.Build();

AuthenticationResult leg1 = await leg1App
.AcquireTokenForClient(new[] { "api://AzureADTokenExchange/.default" })
.WithMtlsProofOfPossession()
.ExecuteAsync();
// leg1.TokenType == "mtls_pop"; leg1.BindingCertificate == the SNI cert (X509Certificate2; may carry a private key).

// Leg 2: carry the Leg-1 assertion + binding cert -> resource token (mtls_pop)
var leg2App = ConfidentialClientApplicationBuilder.Create(clientId)
.WithAuthority(authority)
.WithClientAssertion((options, ct) => Task.FromResult(new ClientSignedAssertion
{
Assertion = leg1.AccessToken, // the federated assertion
TokenBindingCertificate = leg1.BindingCertificate // carry the SAME cert forward
}))
.Build();

AuthenticationResult leg2 = await leg2App
.AcquireTokenForClient(new[] { "https://vault.azure.net/.default" })
.WithMtlsProofOfPossession()
.ExecuteAsync();
// leg2.TokenType == "mtls_pop", bound to leg1.BindingCertificate.Thumbprint.
```

### Exchange audience is caller-supplied

MSAL does **not** hardcode the exchange audience — it passes through whatever scope the caller requests
at Leg 1:

- **Generic S2S FIC:** `api://AzureADTokenExchange/.default`.
- **FMI variant:** `api://AzureFMITokenExchange/.default`, using the reserved client id
`urn:microsoft:identity:fmi`.

Both produce the same PoP outcome; the choice is the caller's.

### Transport ownership (mTLS requires MSAL's built-in handler)

mTLS requires MSAL to **own the HTTP transport handler** so it can attach the client certificate to the
outbound TLS connection. The built-in factory (`IMsalMtlsHttpClientFactory` / `SimpleHttpClientFactory`,
which is the default) does this. A caller-supplied **plain** `IMsalHttpClientFactory` (a bare
`HttpClient`) **cannot** carry the mTLS certificate — supply an mTLS-capable factory or rely on the
built-in transport.
Comment thread
Robbie-Microsoft marked this conversation as resolved.

## Tests to Validate mTLS PoP Tokens

### Certificate Validation Tests
Expand All @@ -114,7 +191,7 @@ AuthenticationResult result = await app.AcquireTokenForClient(scopes).WithMtlsPr

### Region Validation Tests

- Ensure `MsalClientException` is thrown when no region is set and `WithMtlsProofOfPossession()` is called.
- Ensure a **no-region** `WithMtlsProofOfPossession()` request succeeds against the **global** `mtlsauth.microsoft.com` endpoint (region is optional; there is no "region required" failure).
- Validate successful token acquisition with a specified region.
- Test auto-detected region functionality and confirm the expected region is used.
- Region is not required if the authority is DSTS.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ public class ClientCredentialsMtlsPopTests
private const string MsiAllowListedAppIdforSNI = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e";
private const string TokenExchangeUrl = "api://AzureADTokenExchange/.default";

// FMI variant of the exchange audience (SME item 5: the exchange audience is caller-supplied,
Comment thread
Robbie-Microsoft marked this conversation as resolved.
// not SDK-hardcoded). The FMI leg uses the reserved client id urn:microsoft:identity:fmi.
private const string FmiClientId = "urn:microsoft:identity:fmi";
private const string FmiTokenExchangeUrl = "api://AzureFMITokenExchange/.default";

// Note A: the final resource must be ESTS allow-listed for mtls_pop (e.g. Key Vault / MS Graph),
// NOT the client app. The two-leg FIC tests below reference this constant; other tests in this
// file still inline the same literal, so update all of them if ESTS switches to the app model.
private const string AllowListedFinalResource = "https://vault.azure.net/.default";

[TestInitialize]
public void TestInitialize()
{
Expand Down Expand Up @@ -450,9 +460,16 @@ public async Task Sni_AssertionFlow_GlobalEndpoint_Uses_JwtPop_And_Succeeds_Test
string assertionJwt = first.AccessToken;
Assert.IsFalse(string.IsNullOrEmpty(assertionJwt), "First leg did not return an access token to reuse as assertion.");

// Leg 1 is cert-bound PoP: assert the token type and that the binding cert is the SNI cert.
Assert.AreEqual(Constants.MtlsPoPTokenType, first.TokenType, "Leg 1 token type should be MTLS PoP.");
Assert.IsNotNull(first.BindingCertificate, "Leg 1 BindingCertificate should be set for cert-bound PoP.");
Assert.AreEqual(cert.Thumbprint, first.BindingCertificate.Thumbprint,
"Leg 1 BindingCertificate must match the SNI certificate.");

// Step 2: build the assertion-based app — NO region configured (global endpoint)
bool assertionProviderCalled = false;
string requestUriSeen = null;
string clientAssertionType = null;

IConfidentialClientApplication assertionApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI)
.WithExperimentalFeatures()
Expand All @@ -464,36 +481,147 @@ public async Task Sni_AssertionFlow_GlobalEndpoint_Uses_JwtPop_And_Succeeds_Test
return Task.FromResult(new ClientSignedAssertion
{
Assertion = assertionJwt,
TokenBindingCertificate = cert
TokenBindingCertificate = first.BindingCertificate // carry the SAME Leg-1 cert forward
});
})
.WithTestLogging()
.Build();

// Step 3: second leg should succeed using global mTLS endpoint
AuthenticationResult second = await assertionApp
.AcquireTokenForClient(new[] { "https://vault.azure.net/.default" })
// Step 3: second leg should succeed using the global mTLS endpoint, returning an mtls_pop
// token bound to the SAME certificate as Leg 1 (binding-cert continuity end-to-end).
AuthenticationResult second = await ExecuteOrInconclusiveOnTokenTypeMismatchAsync(() => assertionApp
.AcquireTokenForClient(new[] { AllowListedFinalResource })
.WithMtlsProofOfPossession()
.OnBeforeTokenRequest(data =>
{
requestUriSeen = data.RequestUri?.ToString();
data.BodyParameters?.TryGetValue("client_assertion_type", out clientAssertionType);
return Task.CompletedTask;
})
.ExecuteAsync()
.ConfigureAwait(false);
.ExecuteAsync()).ConfigureAwait(false);

// Success assertions
Assert.IsNotNull(second, "Second leg returned null AuthenticationResult.");
Assert.IsFalse(string.IsNullOrEmpty(second.AccessToken), "Second leg did not return an access token.");
Assert.IsTrue(assertionProviderCalled, "Client assertion provider should have been invoked.");

// Leg 2 is also mtls_pop, presents the jwt-pop client_assertion_type, and is bound to the
// SAME certificate as Leg 1 (binding-cert continuity).
Assert.AreEqual(Constants.MtlsPoPTokenType, second.TokenType, "Leg 2 token type should be MTLS PoP.");
Assert.AreEqual(
"urn:ietf:params:oauth:client-assertion-type:jwt-pop",
clientAssertionType,
"Leg 2 must present the federated assertion with the jwt-pop client_assertion_type.");
Assert.IsNotNull(second.BindingCertificate, "Leg 2 BindingCertificate should be set for mtls_pop.");
Assert.AreEqual(first.BindingCertificate.Thumbprint, second.BindingCertificate.Thumbprint,
"The final token must be bound to the SAME certificate as Leg 1 (binding-cert continuity).");

// Verify global mTLS endpoint was used
Assert.IsFalse(string.IsNullOrEmpty(requestUriSeen), "Expected token request URI to be captured.");
var requestUri = new System.Uri(requestUriSeen);
Assert.AreEqual("mtlsauth.microsoft.com", requestUri.Host,
"Should use global mtlsauth endpoint when no region is configured.");
}

[RunOn(SkipConditions.Linux)] // POP is not supported on Linux
public async Task Sni_TwoLeg_S2sFic_FmiAudience_BothLegs_Pop_EndToEnd_TestAsync()
{
// Same two-leg flow, but using the FMI exchange audience (api://AzureFMITokenExchange) and the
// reserved FMI client id (urn:microsoft:identity:fmi) for BOTH legs. Proves the exchange audience
// is caller-supplied (SME item 5). Using the same client id for both legs means an ESTS rejection
// is unambiguously an FMI + mTLS PoP enablement issue (marked inconclusive), not a client-id
// mismatch between legs.
try
{
await RunTwoLegS2sFicBothLegsPopAsync(FmiClientId, FmiClientId, FmiTokenExchangeUrl).ConfigureAwait(false);
}
catch (MsalServiceException ex)
{
Assert.Inconclusive(
"FMI-audience mTLS PoP exchange was rejected by ESTS for this app/lab configuration. " +
"This variant only proves the exchange audience is caller-supplied; the generic " +
$"api://AzureADTokenExchange path remains under test. Underlying error: {ex.Message}");
}
Comment on lines +534 to +544
}

// Drives the two-leg S2S FIC over mTLS PoP end-to-end flow for the FMI-audience variant. Both legs
// use the global mtlsauth endpoint (no region) so they reliably return token_type=mtls_pop, and each
// leg is wrapped in ExecuteOrInconclusiveOnTokenTypeMismatchAsync to tolerate a server-side downgrade.
// Leg 1 and Leg 2 client ids are supplied explicitly so callers keep them consistent — a mismatch
// would make an ESTS rejection ambiguous (enablement vs. client-id) rather than a clean inconclusive.
private static async Task RunTwoLegS2sFicBothLegsPopAsync(string leg1ClientId, string leg2ClientId, string leg1ExchangeScope)
{
_ = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false);

X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName);

// ----- Leg 1: SNI cert -> federated assertion (mtls_pop) on the global endpoint -----
IConfidentialClientApplication leg1App = ConfidentialClientApplicationBuilder.Create(leg1ClientId)
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
.WithCertificate(cert, true)
.WithTestLogging()
.Build();

AuthenticationResult leg1 = await ExecuteOrInconclusiveOnTokenTypeMismatchAsync(() => leg1App
.AcquireTokenForClient(new[] { leg1ExchangeScope })
.WithMtlsProofOfPossession()
.ExecuteAsync()).ConfigureAwait(false);

Assert.IsNotNull(leg1, "Leg 1 returned null AuthenticationResult.");
Assert.AreEqual(Constants.MtlsPoPTokenType, leg1.TokenType, "Leg 1 token type should be MTLS PoP.");
Assert.IsFalse(string.IsNullOrEmpty(leg1.AccessToken), "Leg 1 did not return a federated assertion.");
Assert.IsNotNull(leg1.BindingCertificate, "Leg 1 BindingCertificate should be set for cert-bound PoP.");
Assert.AreEqual(cert.Thumbprint, leg1.BindingCertificate.Thumbprint,
"Leg 1 BindingCertificate must match the SNI certificate.");

// ----- Leg 2: carry Leg-1 binding cert -> resource token (mtls_pop) on the global endpoint -----
string leg2ClientAssertionType = null;
string leg2RequestUri = null;

IConfidentialClientApplication leg2App = ConfidentialClientApplicationBuilder.Create(leg2ClientId)
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
.WithClientAssertion((AssertionRequestOptions options, CancellationToken ct) =>
Task.FromResult(new ClientSignedAssertion
{
Assertion = leg1.AccessToken, // Leg-1 federated assertion
TokenBindingCertificate = leg1.BindingCertificate // carry the SAME cert forward
}))
.WithTestLogging()
.Build();

AuthenticationResult leg2 = await ExecuteOrInconclusiveOnTokenTypeMismatchAsync(() => leg2App
.AcquireTokenForClient(new[] { AllowListedFinalResource })
.WithMtlsProofOfPossession()
.OnBeforeTokenRequest(data =>
{
leg2RequestUri = data.RequestUri?.ToString();
data.BodyParameters?.TryGetValue("client_assertion_type", out leg2ClientAssertionType);
return Task.CompletedTask;
})
.ExecuteAsync()).ConfigureAwait(false);

// Both legs are PoP and the binding certificate is continuous end-to-end.
Assert.IsNotNull(leg2, "Leg 2 returned null AuthenticationResult.");
Assert.AreEqual(Constants.MtlsPoPTokenType, leg2.TokenType, "Leg 2 token type should be MTLS PoP.");
Assert.IsFalse(string.IsNullOrEmpty(leg2.AccessToken), "Leg 2 did not return an access token.");
CollectionAssert.Contains(leg2.Scopes.ToArray(), AllowListedFinalResource,
"Leg 2 token is not for the requested resource.");

Assert.AreEqual(
"urn:ietf:params:oauth:client-assertion-type:jwt-pop",
leg2ClientAssertionType,
"Leg 2 must present the federated assertion with the jwt-pop client_assertion_type.");

Assert.IsNotNull(leg2.BindingCertificate, "Leg 2 BindingCertificate should be set for mtls_pop.");
Assert.AreEqual(leg1.BindingCertificate.Thumbprint, leg2.BindingCertificate.Thumbprint,
"The final token must be bound to the SAME certificate as Leg 1 (binding-cert continuity).");

// Global mtlsauth endpoint (no region) reliably honors token_type=mtls_pop.
Assert.IsFalse(string.IsNullOrEmpty(leg2RequestUri), "Expected Leg 2 token request URI to be captured.");
Assert.AreEqual("mtlsauth.microsoft.com", new System.Uri(leg2RequestUri).Host,
"Leg 2 should use the global mtlsauth endpoint when no region is configured.");
}

// TODO: Remove once the AAD westus3 test-slice mtlsauth endpoint reliably honors
// token_type=mtls_pop. Today the test slice intermittently downgrades to Bearer,
// which is a server-side issue, not a MSAL regression. The global mtlsauth endpoint
Expand Down
Loading
Loading