Skip to content

Commit ead58c8

Browse files
gladjohnCopilotCopilot
authored
Remove region as hard requirement for mTLS PoP flows (#5902)
* Remove region as hard requirement for mTLS PoP flows mTLS is now supported on global endpoints, so region should be optional. When region is available, regional mtlsauth endpoints are used as before. When no region is available, the global mtlsauth endpoint is used instead of throwing an exception. Changes: - Remove region null checks in MtlsPopParametersInitializer - Remove region validation in AcquireTokenForClientParameterBuilder.Validate() - Add global mTLS endpoint fallback in RegionAndMtlsDiscoveryProvider - Update tests that expected region-required exceptions to verify success - Add new unit tests for global mtlsauth endpoints (public, sovereign, non-standard) - Add integration tests for mTLS PoP without region Fixes: #5865 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix formatting and add missing closing braces * Address PR review feedback - Use StringComparison.OrdinalIgnoreCase for host.StartsWith checks in GetGlobalMtlsEnvironment and GetRegionalizedEnvironment - Remove unused 'cert' variables in ClientAssertionTests - Tighten assertions: use exact URI host equality instead of StringAssert.Contains for global endpoint validation - Wrap all global-endpoint tests in EnvVariableContext with REGION_NAME cleared for deterministic behavior - Fix unused appConfig variable in integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clear MSAL_FORCE_REGION in all global endpoint tests All no-region tests now explicitly clear both REGION_NAME and MSAL_FORCE_REGION within EnvVariableContext for deterministic behavior regardless of test runner environment. Co-authored-by: Copilot <223556219+Copilot@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 0beac66 commit ead58c8

6 files changed

Lines changed: 373 additions & 93 deletions

File tree

src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -193,20 +193,6 @@ internal override Task<AuthenticationResult> ExecuteInternalAsync(CancellationTo
193193
/// <seealso cref="ConfidentialClientApplicationBuilder.Validate"/> for a comment inside this function for AzureRegion.
194194
protected override void Validate()
195195
{
196-
if (CommonParameters.MtlsCertificate != null)
197-
{
198-
// Check for Azure region only if the authority is AAD
199-
// AzureRegion is by default set to null or set to null when the application is created
200-
// with region set to DisableForceRegion (see ConfidentialClientApplicationBuilder.Validate)
201-
if (ServiceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad &&
202-
ServiceBundle.Config.AzureRegion == null)
203-
{
204-
throw new MsalClientException(
205-
MsalError.MtlsPopWithoutRegion,
206-
MsalErrorMessage.MtlsPopWithoutRegion);
207-
}
208-
}
209-
210196
base.Validate();
211197

212198
// Force refresh + AccessTokenHashToRefresh APIs cannot be used together

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

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ private static async Task TryInitImplicitBearerOverMtlsAsync(
4444
{
4545
if (tokenParameters.MtlsCertificate != null)
4646
{
47-
ThrowIfRegionMissingForImplicitMtls(serviceBundle);
4847
return;
4948
}
5049

@@ -59,7 +58,6 @@ private static async Task TryInitImplicitBearerOverMtlsAsync(
5958
if (ar?.TokenBindingCertificate != null)
6059
{
6160
tokenParameters.MtlsCertificate = ar.TokenBindingCertificate;
62-
ThrowIfRegionMissingForImplicitMtls(serviceBundle);
6361
}
6462
}
6563
}
@@ -146,29 +144,11 @@ private static void InitMtlsPopParameters(
146144
MsalError.MissingTenantedAuthority,
147145
MsalErrorMessage.MtlsNonTenantedAuthorityNotAllowedMessage);
148146
}
149-
150-
if (serviceBundle.Config.AzureRegion == null)
151-
{
152-
throw new MsalClientException(
153-
MsalError.MtlsPopWithoutRegion,
154-
MsalErrorMessage.MtlsPopWithoutRegion);
155-
}
156147
}
157148

158149
p.AuthenticationOperation = new MtlsPopAuthenticationOperation(cert);
159150
p.MtlsCertificate = cert;
160151
}
161152

162-
private static void ThrowIfRegionMissingForImplicitMtls(IServiceBundle serviceBundle)
163-
{
164-
// Implicit bearer-over-mTLS requires region only for AAD
165-
if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad &&
166-
serviceBundle.Config.AzureRegion == null)
167-
{
168-
throw new MsalClientException(
169-
MsalError.MtlsBearerWithoutRegion,
170-
MsalErrorMessage.MtlsBearerWithoutRegion);
171-
}
172-
}
173153
}
174154
}

src/client/Microsoft.Identity.Client/Instance/Discovery/RegionAndMtlsDiscoveryProvider.cs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,10 @@ public async Task<InstanceDiscoveryMetadataEntry> GetMetadataAsync(Uri authority
6868
{
6969
if (isMtlsEnabled)
7070
{
71-
requestContext.Logger.Info("[Region discovery] Region discovery failed during mTLS Pop. ");
72-
73-
throw new MsalServiceException(
74-
MsalError.RegionRequiredForMtlsPop,
75-
MsalErrorMessage.RegionRequiredForMtlsPopMessage);
71+
// Region is not available — use the global mTLS endpoint
72+
string globalMtlsEnv = GetGlobalMtlsEnvironment(authority, requestContext);
73+
requestContext.Logger.Info($"[Region discovery] Region not available. Using global mTLS environment: {globalMtlsEnv}");
74+
return CreateEntry(authority.Host, globalMtlsEnv);
7675
}
7776

7877
requestContext.Logger.Info("[Region discovery] Not using a regional authority. ");
@@ -99,6 +98,29 @@ private static InstanceDiscoveryMetadataEntry CreateEntry(string originalEnv, st
9998
};
10099
}
101100

101+
private static string GetGlobalMtlsEnvironment(Uri authority, RequestContext requestContext)
102+
{
103+
string host = authority.Host;
104+
105+
if (KnownMetadataProvider.IsPublicEnvironment(host))
106+
{
107+
return PublicEnvForRegionalMtlsAuth;
108+
}
109+
110+
if (KnownMetadataProvider.TryGetKnownEnviromentPreferredNetwork(host, out var preferredNetworkEnv))
111+
{
112+
host = preferredNetworkEnv;
113+
}
114+
115+
// Replace "login" with "mtlsauth" for mTLS scenarios
116+
if (host.StartsWith("login.", StringComparison.OrdinalIgnoreCase))
117+
{
118+
host = "mtlsauth" + host.Substring("login".Length);
119+
}
120+
121+
return host;
122+
}
123+
102124
private static string GetRegionalizedEnvironment(Uri authority, string region, RequestContext requestContext)
103125
{
104126
string host = authority.Host;
@@ -127,7 +149,7 @@ private static string GetRegionalizedEnvironment(Uri authority, string region, R
127149
if (requestContext.IsMtlsRequested)
128150
{
129151
// Modify the host to replace "login" with "mtlsauth" for mTLS scenarios
130-
if (host.StartsWith("login"))
152+
if (host.StartsWith("login.", StringComparison.OrdinalIgnoreCase))
131153
{
132154
host = "mtlsauth" + host.Substring("login".Length);
133155
}

tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/ClientCredentialsMtlsPopTests.cs

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ namespace Microsoft.Identity.Test.Integration.HeadlessTests
2121
// POP tests only work on the allow listed SNI app
2222
// and tenant ("bea21ebe-8b64-4d06-9f6d-6a889b120a7c") - MSI team tenant
2323
[TestClass]
24-
public class ClientCredentialsMtlsPopTests
24+
public class ClientCredentialsMtlsPopTests
2525
{
2626
private const string MsiAllowListedAppIdforSNI = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e";
2727
private const string TokenExchangeUrl = "api://AzureADTokenExchange/.default";
@@ -46,7 +46,7 @@ public async Task Sni_Gets_Pop_Token_Successfully_TestAsync()
4646
IConfidentialClientApplication confidentialApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI)
4747
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
4848
.WithAzureRegion("westus3") //test slice region
49-
.WithCertificate(cert, true)
49+
.WithCertificate(cert, true)
5050
.WithTestLogging()
5151
.Build();
5252

@@ -83,6 +83,68 @@ public async Task Sni_Gets_Pop_Token_Successfully_TestAsync()
8383
"BindingCertificate must match the certificate supplied via WithCertificate().");
8484
}
8585

86+
[RunOn(SkipConditions.Linux)] // POP is not supported on Linux
87+
public async Task Sni_Gets_Pop_Token_WithGlobalEndpoint_TestAsync()
88+
{
89+
// Arrange: validate lab setup before executing the test flow.
90+
_ = await LabResponseHelper.GetAppConfigAsync(KeyVaultSecrets.AppS2S).ConfigureAwait(false);
91+
92+
X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName);
93+
94+
string[] appScopes = new[] { "https://vault.azure.net/.default" };
95+
96+
// Build Confidential Client Application with SNI certificate — NO region configured
97+
IConfidentialClientApplication confidentialApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI)
98+
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
99+
.WithCertificate(cert, true)
100+
.WithTestLogging()
101+
.Build();
102+
103+
// Act: Acquire token with MTLS Proof of Possession at Request level (global endpoint)
104+
AuthenticationResult authResult = await confidentialApp
105+
.AcquireTokenForClient(appScopes)
106+
.WithMtlsProofOfPossession()
107+
.ExecuteAsync()
108+
.ConfigureAwait(false);
109+
110+
// Assert: Check that the MTLS PoP token acquisition was successful
111+
Assert.IsNotNull(authResult, "The authentication result should not be null.");
112+
Assert.AreEqual(Constants.MtlsPoPTokenType, authResult.TokenType, "Token type should be MTLS PoP");
113+
Assert.IsNotNull(authResult.AccessToken, "Access token should not be null");
114+
115+
Assert.IsNotNull(authResult.BindingCertificate, "BindingCertificate should be set in SNI flow.");
116+
Assert.AreEqual(cert.Thumbprint,
117+
authResult.BindingCertificate.Thumbprint,
118+
"BindingCertificate must match the certificate supplied via WithCertificate().");
119+
120+
// Verify global mTLS endpoint was used (no region prefix)
121+
Assert.IsTrue(
122+
System.Uri.TryCreate(
123+
authResult.AuthenticationResultMetadata.TokenEndpoint,
124+
System.UriKind.Absolute,
125+
out System.Uri tokenEndpointUri),
126+
"Token endpoint should be a valid absolute URI.");
127+
Assert.AreEqual(
128+
"mtlsauth.microsoft.com",
129+
tokenEndpointUri.Host,
130+
"Should use global mtlsauth endpoint when no region is configured.");
131+
132+
// Simulate cache retrieval to verify MTLS configuration is cached properly
133+
authResult = await confidentialApp
134+
.AcquireTokenForClient(appScopes)
135+
.WithMtlsProofOfPossession()
136+
.ExecuteAsync()
137+
.ConfigureAwait(false);
138+
139+
// Assert: Verify that the token was fetched from cache on the second request
140+
Assert.AreEqual(TokenSource.Cache, authResult.AuthenticationResultMetadata.TokenSource, "Token should be retrieved from cache");
141+
142+
Assert.IsNotNull(authResult.BindingCertificate, "BindingCertificate should be set in SNI flow.");
143+
Assert.AreEqual(cert.Thumbprint,
144+
authResult.BindingCertificate.Thumbprint,
145+
"BindingCertificate must match the certificate supplied via WithCertificate().");
146+
}
147+
86148
[RunOn(SkipConditions.Linux)]
87149
public async Task Sni_AssertionFlow_Uses_JwtPop_And_Succeeds_TestAsync()
88150
{
@@ -277,5 +339,71 @@ public async Task Sni_AssertionFlow_Uses_JwtPop_And_Acquires_Bearer_Token_TestAs
277339
// Optional: if you rely on regional mTLS endpoints, check the host
278340
StringAssert.Contains(requestUriSeen ?? "", "mtlsauth.microsoft.com");
279341
}
342+
343+
[RunOn(SkipConditions.Linux)]
344+
public async Task Sni_AssertionFlow_GlobalEndpoint_Uses_JwtPop_And_Succeeds_TestAsync()
345+
{
346+
X509Certificate2 cert = CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName);
347+
348+
// Step 1: obtain a real JWT to reuse as the "assertion" — using regional for first leg
349+
IConfidentialClientApplication firstApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI)
350+
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
351+
.WithAzureRegion("westus3")
352+
.WithCertificate(cert, true)
353+
.WithTestLogging()
354+
.Build();
355+
356+
AuthenticationResult first = await firstApp
357+
.AcquireTokenForClient(new[] { TokenExchangeUrl })
358+
.WithMtlsProofOfPossession()
359+
.ExecuteAsync()
360+
.ConfigureAwait(false);
361+
362+
string assertionJwt = first.AccessToken;
363+
Assert.IsFalse(string.IsNullOrEmpty(assertionJwt), "First leg did not return an access token to reuse as assertion.");
364+
365+
// Step 2: build the assertion-based app — NO region configured (global endpoint)
366+
bool assertionProviderCalled = false;
367+
string requestUriSeen = null;
368+
369+
IConfidentialClientApplication assertionApp = ConfidentialClientApplicationBuilder.Create(MsiAllowListedAppIdforSNI)
370+
.WithExperimentalFeatures()
371+
.WithAuthority("https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c")
372+
.WithClientAssertion((AssertionRequestOptions options, CancellationToken ct) =>
373+
{
374+
assertionProviderCalled = true;
375+
376+
return Task.FromResult(new ClientSignedAssertion
377+
{
378+
Assertion = assertionJwt,
379+
TokenBindingCertificate = cert
380+
});
381+
})
382+
.WithTestLogging()
383+
.Build();
384+
385+
// Step 3: second leg should succeed using global mTLS endpoint
386+
AuthenticationResult second = await assertionApp
387+
.AcquireTokenForClient(new[] { "https://vault.azure.net/.default" })
388+
.WithMtlsProofOfPossession()
389+
.OnBeforeTokenRequest(data =>
390+
{
391+
requestUriSeen = data.RequestUri?.ToString();
392+
return Task.CompletedTask;
393+
})
394+
.ExecuteAsync()
395+
.ConfigureAwait(false);
396+
397+
// Success assertions
398+
Assert.IsNotNull(second, "Second leg returned null AuthenticationResult.");
399+
Assert.IsFalse(string.IsNullOrEmpty(second.AccessToken), "Second leg did not return an access token.");
400+
Assert.IsTrue(assertionProviderCalled, "Client assertion provider should have been invoked.");
401+
402+
// Verify global mTLS endpoint was used
403+
Assert.IsFalse(string.IsNullOrEmpty(requestUriSeen), "Expected token request URI to be captured.");
404+
var requestUri = new System.Uri(requestUriSeen);
405+
Assert.AreEqual("mtlsauth.microsoft.com", requestUri.Host,
406+
"Should use global mtlsauth endpoint when no region is configured.");
407+
}
280408
}
281409
}

tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

44
using System;
@@ -702,28 +702,31 @@ public async Task ClientAssertion_NotCalledWhenTokenFromCacheAsync()
702702
}
703703

704704
[TestMethod]
705-
public async Task WithMtlsPop_AfterPoPDelegate_NoRegion_ThrowsAsync()
705+
public async Task WithMtlsPop_AfterPoPDelegate_NoRegion_UsesGlobalEndpointAsync()
706706
{
707707
using var http = new MockHttpManager();
708708
{
709709
// Arrange – CCA with PoP delegate (returns JWT + cert) but **no AzureRegion configured**
710-
var cert = CertHelper.GetOrCreateTestCert();
710+
http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(
711+
tokenType: "mtls_pop");
712+
711713
var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
712714
.WithExperimentalFeatures(true)
713715
.WithAuthority(TestConstants.AadAuthorityWithMsftTenantId)
714716
.WithClientAssertion(PopDelegate())
715717
.WithHttpManager(http)
716718
.BuildConcrete();
717719

718-
// Act & Assert – should fail because region is missing
719-
var ex = await AssertException.TaskThrowsAsync<MsalClientException>(async () =>
720-
await cca.AcquireTokenForClient(TestConstants.s_scope)
721-
.WithMtlsProofOfPossession()
722-
.ExecuteAsync()
723-
.ConfigureAwait(false))
720+
// Act – should succeed using global mTLS endpoint
721+
AuthenticationResult result = await cca.AcquireTokenForClient(TestConstants.s_scope)
722+
.WithMtlsProofOfPossession()
723+
.ExecuteAsync()
724724
.ConfigureAwait(false);
725725

726-
Assert.AreEqual(MsalError.MtlsPopWithoutRegion, ex.ErrorCode);
726+
Assert.IsNotNull(result.AccessToken);
727+
Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType);
728+
var tokenEndpointUri = new Uri(result.AuthenticationResultMetadata.TokenEndpoint);
729+
Assert.AreEqual("mtlsauth.microsoft.com", tokenEndpointUri.Host);
727730
}
728731
}
729732

@@ -807,26 +810,28 @@ public async Task BearerClientAssertion_WithPoPDelegate_CanReturnDifferentPairsA
807810
}
808811

809812
[TestMethod]
810-
public async Task WithMtlsAssertion_NoRegion_ThrowsAsync()
813+
public async Task WithMtlsAssertion_NoRegion_UsesGlobalEndpointAsync()
811814
{
812815
using var http = new MockHttpManager();
813816
{
814817
// Arrange – CCA with PoP delegate (returns JWT + cert) but **no AzureRegion configured**
815-
var cert = CertHelper.GetOrCreateTestCert();
818+
http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
819+
816820
var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
817821
.WithExperimentalFeatures(true)
822+
.WithAuthority(TestConstants.AadAuthorityWithMsftTenantId)
818823
.WithClientAssertion(PopDelegate())
819824
.WithHttpManager(http)
820825
.BuildConcrete();
821826

822-
// Act & Assert – should fail because region is missing
823-
var ex = await AssertException.TaskThrowsAsync<MsalClientException>(async () =>
824-
await cca.AcquireTokenForClient(TestConstants.s_scope)
825-
.ExecuteAsync()
826-
.ConfigureAwait(false))
827+
// Act – should succeed using global mTLS endpoint
828+
AuthenticationResult result = await cca.AcquireTokenForClient(TestConstants.s_scope)
829+
.ExecuteAsync()
827830
.ConfigureAwait(false);
828831

829-
Assert.AreEqual(MsalError.MtlsBearerWithoutRegion, ex.ErrorCode);
832+
Assert.IsNotNull(result.AccessToken);
833+
var tokenEndpoint = new Uri(result.AuthenticationResultMetadata.TokenEndpoint, UriKind.Absolute);
834+
Assert.AreEqual("mtlsauth.microsoft.com", tokenEndpoint.Host);
830835
}
831836
}
832837

0 commit comments

Comments
 (0)