diff --git a/CHANGELOG.md b/CHANGELOG.md index 4297ce313c..ff7717261e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ====== diff --git a/docs/mtls_pop_skills_guide.md b/docs/mtls_pop_skills_guide.md index d2079be62d..9d953c75ef 100644 --- a/docs/mtls_pop_skills_guide.md +++ b/docs/mtls_pop_skills_guide.md @@ -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 ``` @@ -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? ``` diff --git a/docs/sni_mtls_pop_token_design.md b/docs/sni_mtls_pop_token_design.md index 04b79bb50d..dc02caeae4 100644 --- a/docs/sni_mtls_pop_token_design.md +++ b/docs/sni_mtls_pop_token_design.md @@ -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.` 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. #### Public Cloud @@ -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 @@ -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. + ## Tests to Validate mTLS PoP Tokens ### Certificate Validation Tests @@ -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. diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs index b12a51be6a..27a788aab5 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs @@ -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, + // 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() { @@ -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() @@ -464,29 +481,41 @@ 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); @@ -494,6 +523,105 @@ public async Task Sni_AssertionFlow_GlobalEndpoint_Uses_JwtPop_And_Succeeds_Test "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}"); + } + } + + // 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 diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs index cdf66805fa..483d830d47 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs @@ -1349,6 +1349,144 @@ public async Task BindingCertificate_PopulatedForMtlsPop_AndNullForBearerAsync() } } + #region SNI trust path and S2S FIC carry-over + + [TestMethod] + public async Task MtlsPop_SniSendX5C_OmitsClientAssertionAndReqCnfAsync() + { + // Vanilla SNI over mTLS PoP: the cert is presented on the TLS connection and ESTS + // resolves Subject Name + Issuer trust from the TLS-presented cert. Even with sendX5C:true, + // the mTLS body must carry NO client_assertion / client_assertion_type / req_cnf — the cert + // (cnf / x5t#S256) is the binding, not a signed assertion. + string authorityUrl = "https://login.microsoftonline.com/123456-1234-2345-1234561234"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", EastUsRegion); + + using (var harness = new MockHttpAndServiceBundle()) + { + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = $"https://{EastUsRegion}.mtlsauth.microsoft.com/123456-1234-2345-1234561234/oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ResponseMessage = CreateResponse(tokenType: "mtls_pop"), + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.ClientCredentials }, + { "token_type", "mtls_pop" } + }, + UnExpectedPostData = new Dictionary + { + { "client_assertion", "n/a" }, + { "client_assertion_type", "n/a" }, + { OAuth2Parameter.RequestConfirmation, "n/a" } + } + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithCertificate(s_testCertificate, sendX5C: true) + .Build(); + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.IsNotNull(result.BindingCertificate); + Assert.AreEqual(s_testCertificate.Thumbprint, result.BindingCertificate.Thumbprint); + } + } + } + + [TestMethod] + public async Task MtlsPop_S2sFic_ClientAssertionCarryOver_SendsJwtPopAndBindsToCarriedCertAsync() + { + // S2S (app) FIC "Leg 2" over mTLS PoP: the caller supplies the Leg-1 federated assertion + // together with the Leg-1 binding certificate via ClientSignedAssertion.TokenBindingCertificate. + // MSAL must forward the assertion as client_assertion, set client_assertion_type to jwt-pop, + // present the carried cert on the mTLS connection, and return a token bound to that cert. + const string leg1Assertion = "eyLeg1.federated.assertion"; + var carriedCert = CertHelper.GetOrCreateTestCert(regenerateCert: true); + Assert.AreNotEqual(s_testCertificate.Thumbprint, carriedCert.Thumbprint); + + string authorityUrl = "https://login.microsoftonline.com/123456-1234-2345-1234561234"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", EastUsRegion); + + using (var harness = new MockHttpAndServiceBundle()) + { + var tokenHttpCallHandler = new MockHttpMessageHandler() + { + ExpectedUrl = $"https://{EastUsRegion}.mtlsauth.microsoft.com/123456-1234-2345-1234561234/oauth2/v2.0/token", + ExpectedMethod = HttpMethod.Post, + ResponseMessage = CreateResponse(tokenType: "mtls_pop"), + ExpectedPostData = new Dictionary + { + { OAuth2Parameter.ClientId, TestConstants.ClientId }, + { OAuth2Parameter.GrantType, OAuth2GrantType.ClientCredentials }, + { "token_type", "mtls_pop" }, + { "client_assertion", leg1Assertion }, + { "client_assertion_type", OAuth2AssertionType.JwtPop } + } + }; + + harness.HttpManager.AddMockHandler(tokenHttpCallHandler); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithAuthority(authorityUrl) + .WithHttpManager(harness.HttpManager) + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithClientAssertion((AssertionRequestOptions _, CancellationToken _) => + Task.FromResult(new ClientSignedAssertion + { + Assertion = leg1Assertion, + TokenBindingCertificate = carriedCert + })) + .Build(); + + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.IsNotNull(result.BindingCertificate); + Assert.AreEqual(carriedCert.Thumbprint, result.BindingCertificate.Thumbprint, + "The final token must be bound to the carried Leg-1 certificate."); + } + } + } + + [TestMethod] + public void MtlsPop_DefaultHttpClientFactory_IsMtlsCapable_TransportOwnedByMsal() + { + // mTLS requires MSAL to own the transport handler so it can attach the client certificate. + // MSAL's default factory must be mTLS-capable (IMsalMtlsHttpClientFactory); a plain + // caller-supplied IMsalHttpClientFactory cannot carry the mTLS cert. + IMsalHttpClientFactory defaultFactory = + Microsoft.Identity.Client.PlatformsCommon.Factories.PlatformProxyFactory + .CreatePlatformProxy(null) + .CreateDefaultHttpClientFactory(); + + Assert.IsInstanceOfType(defaultFactory, typeof(IMsalMtlsHttpClientFactory), + "MSAL's default HTTP transport must be mTLS-capable so it can present the client certificate " + + "on the mutual-TLS connection to the token endpoint."); + } + + #endregion + #region SendCertificateOverMtls tests [TestMethod]