From e2cce82a803d729ee579842ac1d7365f824af89f Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Sat, 8 Aug 2026 01:05:57 -0700 Subject: [PATCH 1/4] Fix tenant parsing for multi-segment STSURL authorities Fixes #4496 The Dataverse/Dynamics 365 TDS endpoint returns an ADAL v1 style STSURL ("https://login.microsoftonline.com/{tenantId}/oauth2/authorize") in the FEDAUTHINFO token. AcquireTokenAsync split the authority at the last '/', so the tenant was parsed as the literal "authorize" and the authority host became ".../oauth2/", causing authentication to fail. The tenant is now taken from the first path segment of the authority URL, ignoring trailing endpoint suffixes, and the normalized authority (host + tenant) is used for the MSAL public client application. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4e906c9-daf3-46f8-83e7-ef805d81fdfb --- .../ActiveDirectoryAuthenticationProvider.cs | 65 +++++++++- .../Azure/test/AuthorityParsingTests.cs | 113 ++++++++++++++++++ 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 52b1da7ba7..c1a8885a43 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -249,10 +249,13 @@ public override async Task AcquireTokenAsync(SqlAuthenti // More information: // // https://docs.microsoft.com/azure/active-directory/develop/msal-client-application-configuration + // + // The authority URL provided by the server may be a bare tenant endpoint + // ("https://login.microsoftonline.com/{tenantId}") or an ADAL v1 style endpoint + // ("https://login.microsoftonline.com/{tenantId}/oauth2/authorize"), so the tenant is + // taken from the first path segment rather than the last. - int separatorIndex = parameters.Authority.LastIndexOf('/'); - string authority = parameters.Authority.Remove(separatorIndex + 1); - string audience = parameters.Authority.Substring(separatorIndex + 1); + ParseAuthority(parameters.Authority, out string authority, out string audience, out string msalAuthority); string? clientId = string.IsNullOrWhiteSpace(parameters.UserId) ? null : parameters.UserId; if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault) @@ -316,9 +319,9 @@ public override async Task AcquireTokenAsync(SqlAuthenti PublicClientAppKey pcaKey = #if NETFRAMEWORK - new(parameters.Authority, redirectUri, _applicationClientId, _iWin32WindowFunc); + new(msalAuthority, redirectUri, _applicationClientId, _iWin32WindowFunc); #else - new(parameters.Authority, redirectUri, _applicationClientId); + new(msalAuthority, redirectUri, _applicationClientId); #endif AuthenticationResult? result = null; @@ -533,6 +536,58 @@ or AuthenticationRequiredException } } + /// + /// Splits an Entra ID authority URL (the STSURL provided by the server in the FEDAUTHINFO TDS + /// token) into the authority host and the tenant. + /// + /// + /// The authority URL, e.g. https://login.microsoftonline.com/{tenantId}. Some services + /// (for example the Dataverse/Dynamics 365 TDS endpoint) return an ADAL v1 style URL such as + /// https://login.microsoftonline.com/{tenantId}/oauth2/authorize. + /// + /// + /// Receives the authority host with a trailing slash, e.g. https://login.microsoftonline.com/. + /// + /// + /// Receives the tenant (the first path segment of the authority URL), which may be a tenant id, + /// a domain name, or one of the common/organizations/consumers placeholders. + /// + /// + /// Receives the normalized authority (host + tenant) suitable for MSAL's WithAuthority. + /// + /// + /// The tenant is taken from the first path segment rather than the last so that trailing + /// endpoint suffixes (/oauth2/authorize, /oauth2/v2.0/token, etc.) are ignored. + /// + internal static void ParseAuthority( + string authorityUrl, + out string authorityHost, + out string tenant, + out string msalAuthority) + { + if (Uri.TryCreate(authorityUrl, UriKind.Absolute, out Uri? uri) && + (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + { + string path = uri.AbsolutePath.Trim('/'); + int slashIndex = path.IndexOf('/'); + tenant = slashIndex < 0 ? path : path.Substring(0, slashIndex); + + if (tenant.Length > 0) + { + authorityHost = uri.GetLeftPart(UriPartial.Authority) + "/"; + msalAuthority = authorityHost + tenant; + return; + } + } + + // Fall back to the legacy behavior of splitting at the last separator when the authority + // isn't an absolute HTTP(S) URL, or when it carries no tenant segment. + int separatorIndex = authorityUrl.LastIndexOf('/'); + authorityHost = authorityUrl.Remove(separatorIndex + 1); + tenant = authorityUrl.Substring(separatorIndex + 1); + msalAuthority = authorityUrl; + } + private static async Task TryAcquireTokenSilent(IPublicClientApplication app, SqlAuthenticationParameters parameters, string[] scopes, CancellationTokenSource cts) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs new file mode 100644 index 0000000000..a107f96f82 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; + +/// +/// Tests for splitting the STSURL supplied by the server into an authority host and a tenant. +/// +public class AuthorityParsingTests +{ + private const string Tenant = "72f988bf-86f1-41af-91ab-2d7cd011db47"; + + public static TheoryData AuthorityData => new() + { + // Azure SQL / Fabric style authority. + { + $"https://login.microsoftonline.com/{Tenant}", + "https://login.microsoftonline.com/", + Tenant, + $"https://login.microsoftonline.com/{Tenant}" + }, + // Trailing slash. + { + $"https://login.microsoftonline.com/{Tenant}/", + "https://login.microsoftonline.com/", + Tenant, + $"https://login.microsoftonline.com/{Tenant}" + }, + // ADAL v1 style authority returned by the Dataverse / Dynamics 365 TDS endpoint. + { + $"https://login.microsoftonline.com/{Tenant}/oauth2/authorize", + "https://login.microsoftonline.com/", + Tenant, + $"https://login.microsoftonline.com/{Tenant}" + }, + // v2.0 token endpoint. + { + $"https://login.microsoftonline.com/{Tenant}/oauth2/v2.0/token", + "https://login.microsoftonline.com/", + Tenant, + $"https://login.microsoftonline.com/{Tenant}" + }, + // Sovereign cloud authority. + { + $"https://login.microsoftonline.us/{Tenant}/oauth2/authorize", + "https://login.microsoftonline.us/", + Tenant, + $"https://login.microsoftonline.us/{Tenant}" + }, + // Domain-name tenant. + { + "https://login.microsoftonline.com/contoso.onmicrosoft.com", + "https://login.microsoftonline.com/", + "contoso.onmicrosoft.com", + "https://login.microsoftonline.com/contoso.onmicrosoft.com" + }, + // Placeholder tenant. + { + "https://login.microsoftonline.com/common/oauth2/authorize", + "https://login.microsoftonline.com/", + "common", + "https://login.microsoftonline.com/common" + }, + // Non-default port is preserved in the authority host. + { + $"https://sts.contoso.com:8443/{Tenant}/oauth2/authorize", + "https://sts.contoso.com:8443/", + Tenant, + $"https://sts.contoso.com:8443/{Tenant}" + }, + }; + + [Theory] + [MemberData(nameof(AuthorityData))] + public void ParseAuthority_SplitsHostAndTenant( + string authorityUrl, + string expectedHost, + string expectedTenant, + string expectedMsalAuthority) + { + ActiveDirectoryAuthenticationProvider.ParseAuthority( + authorityUrl, + out string host, + out string tenant, + out string msalAuthority); + + Assert.Equal(expectedHost, host); + Assert.Equal(expectedTenant, tenant); + Assert.Equal(expectedMsalAuthority, msalAuthority); + } + + [Theory] + // No tenant segment at all. + [InlineData("https://login.microsoftonline.com/", "https://login.microsoftonline.com/", "")] + // Not an absolute HTTP(S) URL - legacy split behavior is retained. + [InlineData("login.microsoftonline.com/tenant", "login.microsoftonline.com/", "tenant")] + public void ParseAuthority_FallsBackToLegacySplit( + string authorityUrl, + string expectedHost, + string expectedTenant) + { + ActiveDirectoryAuthenticationProvider.ParseAuthority( + authorityUrl, + out string host, + out string tenant, + out string msalAuthority); + + Assert.Equal(expectedHost, host); + Assert.Equal(expectedTenant, tenant); + Assert.Equal(authorityUrl, msalAuthority); + } +} From 8866e3f5660759b1d5d39163169556f80e602a18 Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Sat, 8 Aug 2026 01:10:24 -0700 Subject: [PATCH 2/4] Require an absolute HTTPS authority with a tenant Entra ID authorities (and therefore the STSURL in FEDAUTHINFO) are always absolute HTTPS URLs, and both MSAL's WithAuthority and Azure.Identity's AuthorityHost require an absolute URI, so the legacy last-separator split could never produce a working credential for anything else. Replace the fallback with TryParseAuthority, which rejects such authorities up front with a clear AuthenticationException instead of failing obscurely later. Also stop re-wrapping AuthenticationException in the generic catch block. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4e906c9-daf3-46f8-83e7-ef805d81fdfb --- .../ActiveDirectoryAuthenticationProvider.cs | 43 ++++++++++---- .../Azure/test/AuthorityParsingTests.cs | 57 +++++++++++-------- 2 files changed, 66 insertions(+), 34 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index c1a8885a43..654ba44206 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -255,7 +255,15 @@ public override async Task AcquireTokenAsync(SqlAuthenti // ("https://login.microsoftonline.com/{tenantId}/oauth2/authorize"), so the tenant is // taken from the first path segment rather than the last. - ParseAuthority(parameters.Authority, out string authority, out string audience, out string msalAuthority); + if (!TryParseAuthority(parameters.Authority, out string authority, out string audience, out string msalAuthority)) + { + throw new Extensions.Azure.AuthenticationException( + parameters.AuthenticationMethod, + $"The authority '{parameters.Authority}' is not a valid Entra ID authority. " + + "Expected an absolute HTTPS URL containing a tenant, " + + "e.g. 'https://login.microsoftonline.com/'."); + } + string? clientId = string.IsNullOrWhiteSpace(parameters.UserId) ? null : parameters.UserId; if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault) @@ -438,6 +446,11 @@ previousPw is byte[] previousPwBytes && return new SqlAuthenticationToken(result.AccessToken, result.ExpiresOn); } + catch (Extensions.Azure.AuthenticationException) + { + // Already shaped for the caller; don't re-wrap it below. + throw; + } catch (MsalException ex) { // Check for an explicitly retryable error. @@ -555,18 +568,30 @@ or AuthenticationRequiredException /// /// Receives the normalized authority (host + tenant) suitable for MSAL's WithAuthority. /// + /// + /// true if the authority URL is a well-formed, absolute HTTPS URL carrying a tenant + /// segment; otherwise false. + /// /// + /// /// The tenant is taken from the first path segment rather than the last so that trailing /// endpoint suffixes (/oauth2/authorize, /oauth2/v2.0/token, etc.) are ignored. + /// + /// + /// Entra ID authorities are always absolute HTTPS URLs, so anything else is rejected rather + /// than guessed at. Both MSAL (WithAuthority) and Azure.Identity + /// (TokenCredentialOptions.AuthorityHost) require an absolute URI as well, so an + /// unparseable authority cannot produce a working credential. + /// /// - internal static void ParseAuthority( + internal static bool TryParseAuthority( string authorityUrl, out string authorityHost, out string tenant, out string msalAuthority) { if (Uri.TryCreate(authorityUrl, UriKind.Absolute, out Uri? uri) && - (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + uri.Scheme == Uri.UriSchemeHttps) { string path = uri.AbsolutePath.Trim('/'); int slashIndex = path.IndexOf('/'); @@ -576,16 +601,14 @@ internal static void ParseAuthority( { authorityHost = uri.GetLeftPart(UriPartial.Authority) + "/"; msalAuthority = authorityHost + tenant; - return; + return true; } } - // Fall back to the legacy behavior of splitting at the last separator when the authority - // isn't an absolute HTTP(S) URL, or when it carries no tenant segment. - int separatorIndex = authorityUrl.LastIndexOf('/'); - authorityHost = authorityUrl.Remove(separatorIndex + 1); - tenant = authorityUrl.Substring(separatorIndex + 1); - msalAuthority = authorityUrl; + authorityHost = string.Empty; + tenant = string.Empty; + msalAuthority = string.Empty; + return false; } private static async Task TryAcquireTokenSilent(IPublicClientApplication app, SqlAuthenticationParameters parameters, diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs index a107f96f82..3d591695a4 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs @@ -5,8 +5,13 @@ namespace Microsoft.Data.SqlClient.Extensions.Azure.Test; /// -/// Tests for splitting the STSURL supplied by the server into an authority host and a tenant. +/// Tests for splitting the STSURL supplied by the server in the FEDAUTHINFO TDS token into an +/// authority host and a tenant. /// +/// +/// The cases below only cover authority shapes that Entra ID actually documents: +/// https://learn.microsoft.com/entra/identity-platform/authentication-national-cloud +/// public class AuthorityParsingTests { private const string Tenant = "72f988bf-86f1-41af-91ab-2d7cd011db47"; @@ -27,7 +32,7 @@ public class AuthorityParsingTests Tenant, $"https://login.microsoftonline.com/{Tenant}" }, - // ADAL v1 style authority returned by the Dataverse / Dynamics 365 TDS endpoint. + // v1.0 authorize endpoint, as returned by the Dataverse / Dynamics 365 TDS endpoint. { $"https://login.microsoftonline.com/{Tenant}/oauth2/authorize", "https://login.microsoftonline.com/", @@ -41,13 +46,20 @@ public class AuthorityParsingTests Tenant, $"https://login.microsoftonline.com/{Tenant}" }, - // Sovereign cloud authority. + // US Government cloud. { $"https://login.microsoftonline.us/{Tenant}/oauth2/authorize", "https://login.microsoftonline.us/", Tenant, $"https://login.microsoftonline.us/{Tenant}" }, + // Microsoft Azure operated by 21Vianet. + { + $"https://login.partner.microsoftonline.cn/{Tenant}", + "https://login.partner.microsoftonline.cn/", + Tenant, + $"https://login.partner.microsoftonline.cn/{Tenant}" + }, // Domain-name tenant. { "https://login.microsoftonline.com/contoso.onmicrosoft.com", @@ -62,28 +74,27 @@ public class AuthorityParsingTests "common", "https://login.microsoftonline.com/common" }, - // Non-default port is preserved in the authority host. { - $"https://sts.contoso.com:8443/{Tenant}/oauth2/authorize", - "https://sts.contoso.com:8443/", - Tenant, - $"https://sts.contoso.com:8443/{Tenant}" + "https://login.microsoftonline.com/organizations", + "https://login.microsoftonline.com/", + "organizations", + "https://login.microsoftonline.com/organizations" }, }; [Theory] [MemberData(nameof(AuthorityData))] - public void ParseAuthority_SplitsHostAndTenant( + public void TryParseAuthority_SplitsHostAndTenant( string authorityUrl, string expectedHost, string expectedTenant, string expectedMsalAuthority) { - ActiveDirectoryAuthenticationProvider.ParseAuthority( + Assert.True(ActiveDirectoryAuthenticationProvider.TryParseAuthority( authorityUrl, out string host, out string tenant, - out string msalAuthority); + out string msalAuthority)); Assert.Equal(expectedHost, host); Assert.Equal(expectedTenant, tenant); @@ -91,23 +102,21 @@ public void ParseAuthority_SplitsHostAndTenant( } [Theory] - // No tenant segment at all. - [InlineData("https://login.microsoftonline.com/", "https://login.microsoftonline.com/", "")] - // Not an absolute HTTP(S) URL - legacy split behavior is retained. - [InlineData("login.microsoftonline.com/tenant", "login.microsoftonline.com/", "tenant")] - public void ParseAuthority_FallsBackToLegacySplit( - string authorityUrl, - string expectedHost, - string expectedTenant) + // A tenant is required; an authority without one cannot yield a usable credential. + [InlineData("https://login.microsoftonline.com")] + [InlineData("https://login.microsoftonline.com/")] + // The server may omit the STSURL entirely. + [InlineData("")] + public void TryParseAuthority_RejectsAuthorityWithoutTenant(string authorityUrl) { - ActiveDirectoryAuthenticationProvider.ParseAuthority( + Assert.False(ActiveDirectoryAuthenticationProvider.TryParseAuthority( authorityUrl, out string host, out string tenant, - out string msalAuthority); + out string msalAuthority)); - Assert.Equal(expectedHost, host); - Assert.Equal(expectedTenant, tenant); - Assert.Equal(authorityUrl, msalAuthority); + Assert.Equal(string.Empty, host); + Assert.Equal(string.Empty, tenant); + Assert.Equal(string.Empty, msalAuthority); } } From e4908aeeb1848a0feb8056f52fa7c84acefe534a Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Mon, 10 Aug 2026 10:36:42 -0700 Subject: [PATCH 3/4] Rename authority locals and TokenCredentialKey fields for clarity Address review feedback: - The 'audience' local no longer held the last path segment after the parsing fix; it holds the tenant that is passed to Azure.Identity as TenantId. Rename the locals and the TokenCredentialKey fields to authorityHost/tenant so the names match what they carry, and refresh the surrounding comment accordingly. - Add a 'consumers' placeholder case to AuthorityParsingTests, which the TryParseAuthority documentation already calls out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4e906c9-daf3-46f8-83e7-ef805d81fdfb --- .../ActiveDirectoryAuthenticationProvider.cs | 73 ++++++++++--------- .../Azure/test/AuthorityParsingTests.cs | 6 ++ 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 654ba44206..45cb0a0cbc 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -222,29 +222,26 @@ public override async Task AcquireTokenAsync(SqlAuthenti string[] scopes = [scope]; TokenRequestContext tokenRequestContext = new(scopes); - // We split audience from Authority URL here. Audience can be one of + // We split the tenant from the Authority URL here. The tenant can be one of // the following: // - // - The Entra ID authority audience enumeration // - The tenant ID, which can be: // - A GUID (the ID of your Entra ID instance), for // single-tenant applications // - A domain name associated with your Entra ID instance (also // for single-tenant applications) - // - One of these placeholders as a tenant ID in place of the - // Entra ID authority audience enumeration: + // - One of these placeholders, which select an Entra ID authority + // audience instead of a specific tenant: // - `organizations` for a multitenant application // - `consumers` to sign in users only with their personal // accounts // - `common` to sign in users with their work and school // accounts or their personal Microsoft accounts // - // MSAL will throw a meaningful exception if you specify both the - // Entra ID authority audience and the tenant ID. - // - // If you don't specify an audience, your app will target Entra ID - // and personal Microsoft accounts as an audience. (That is, it - // will behave as though `common` were specified.) + // If no tenant is specified, the app targets Entra ID and personal + // Microsoft accounts as an audience. (That is, it behaves as though + // `common` were specified.) We always have a tenant here, because the + // server supplies one in the STSURL. // // More information: // @@ -255,7 +252,7 @@ public override async Task AcquireTokenAsync(SqlAuthenti // ("https://login.microsoftonline.com/{tenantId}/oauth2/authorize"), so the tenant is // taken from the first path segment rather than the last. - if (!TryParseAuthority(parameters.Authority, out string authority, out string audience, out string msalAuthority)) + if (!TryParseAuthority(parameters.Authority, out string authorityHost, out string tenant, out string msalAuthority)) { throw new Extensions.Azure.AuthenticationException( parameters.AuthenticationMethod, @@ -268,8 +265,8 @@ public override async Task AcquireTokenAsync(SqlAuthenti if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault) { - // Cache DefaultAzureCredenial based on scope, authority, audience, and clientId - TokenCredentialKey tokenCredentialKey = new(typeof(DefaultAzureCredential), authority, scope, audience, clientId); + // Cache DefaultAzureCredenial based on scope, authority host, tenant, and clientId + TokenCredentialKey tokenCredentialKey = new(typeof(DefaultAzureCredential), authorityHost, scope, tenant, clientId); AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, string.Empty, tokenRequestContext, cts.Token).ConfigureAwait(false); SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token for Default auth mode. Expiry Time: {0}", accessToken.ExpiresOn); return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn); @@ -277,8 +274,8 @@ public override async Task AcquireTokenAsync(SqlAuthenti if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryManagedIdentity || parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryMSI) { - // Cache ManagedIdentityCredential based on scope, authority, and clientId - TokenCredentialKey tokenCredentialKey = new(typeof(ManagedIdentityCredential), authority, scope, string.Empty, clientId); + // Cache ManagedIdentityCredential based on scope, authority host, and clientId + TokenCredentialKey tokenCredentialKey = new(typeof(ManagedIdentityCredential), authorityHost, scope, string.Empty, clientId); AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, string.Empty, tokenRequestContext, cts.Token).ConfigureAwait(false); SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token for Managed Identity auth mode. Expiry Time: {0}", accessToken.ExpiresOn); return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn); @@ -286,8 +283,8 @@ public override async Task AcquireTokenAsync(SqlAuthenti if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryServicePrincipal) { - // Cache ClientSecretCredential based on scope, authority, audience, and clientId - TokenCredentialKey tokenCredentialKey = new(typeof(ClientSecretCredential), authority, scope, audience, clientId); + // Cache ClientSecretCredential based on scope, authority host, tenant, and clientId + TokenCredentialKey tokenCredentialKey = new(typeof(ClientSecretCredential), authorityHost, scope, tenant, clientId); string password = parameters.Password is null ? string.Empty : parameters.Password; AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, password, tokenRequestContext, cts.Token).ConfigureAwait(false); SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token for Active Directory Service Principal auth mode. Expiry Time: {0}", accessToken.ExpiresOn); @@ -296,8 +293,8 @@ public override async Task AcquireTokenAsync(SqlAuthenti if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryWorkloadIdentity) { - // Cache WorkloadIdentityCredential based on authority and clientId - TokenCredentialKey tokenCredentialKey = new(typeof(WorkloadIdentityCredential), authority, string.Empty, string.Empty, clientId); + // Cache WorkloadIdentityCredential based on authority host and clientId + TokenCredentialKey tokenCredentialKey = new(typeof(WorkloadIdentityCredential), authorityHost, string.Empty, string.Empty, clientId); // If either tenant id, client id, or the token file path are not specified when fetching the token, // a CredentialUnavailableException will be thrown instead AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, string.Empty, tokenRequestContext, cts.Token).ConfigureAwait(false); @@ -942,8 +939,8 @@ private static TokenCredentialData CreateTokenCredentialInstance(TokenCredential { DefaultAzureCredentialOptions defaultAzureCredentialOptions = new() { - AuthorityHost = new Uri(tokenCredentialKey._authority), - TenantId = tokenCredentialKey._audience, + AuthorityHost = new Uri(tokenCredentialKey._authorityHost), + TenantId = tokenCredentialKey._tenant, ExcludeInteractiveBrowserCredential = true // Force disabled, even though it's disabled by default to respect driver specifications. }; @@ -987,23 +984,23 @@ private static TokenCredentialData CreateTokenCredentialInstance(TokenCredential : ManagedIdentityId.FromUserAssignedClientId(tokenCredentialKey._clientId); ManagedIdentityCredentialOptions managedIdentityCredentialOptions = new(managedIdentityId) { - AuthorityHost = new Uri(tokenCredentialKey._authority) + AuthorityHost = new Uri(tokenCredentialKey._authorityHost) }; return new TokenCredentialData(new ManagedIdentityCredential(managedIdentityCredentialOptions), GetHash(secret)); } else if (tokenCredentialKey._tokenCredentialType == typeof(ClientSecretCredential)) { - TokenCredentialOptions tokenCredentialOptions = new() { AuthorityHost = new Uri(tokenCredentialKey._authority) }; + TokenCredentialOptions tokenCredentialOptions = new() { AuthorityHost = new Uri(tokenCredentialKey._authorityHost) }; - return new TokenCredentialData(new ClientSecretCredential(tokenCredentialKey._audience, tokenCredentialKey._clientId, secret, tokenCredentialOptions), GetHash(secret)); + return new TokenCredentialData(new ClientSecretCredential(tokenCredentialKey._tenant, tokenCredentialKey._clientId, secret, tokenCredentialOptions), GetHash(secret)); } else if (tokenCredentialKey._tokenCredentialType == typeof(WorkloadIdentityCredential)) { // The WorkloadIdentityCredentialOptions object initialization populates its instance members // from the environment variables AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, // and AZURE_ADDITIONALLY_ALLOWED_TENANTS. AZURE_CLIENT_ID may be overridden by the User Id. - WorkloadIdentityCredentialOptions options = new() { AuthorityHost = new Uri(tokenCredentialKey._authority) }; + WorkloadIdentityCredentialOptions options = new() { AuthorityHost = new Uri(tokenCredentialKey._authorityHost) }; if (tokenCredentialKey._clientId is not null) { @@ -1083,17 +1080,27 @@ public TokenCredentialData(TokenCredential tokenCredential, byte[] secretHash) internal class TokenCredentialKey { public readonly Type _tokenCredentialType; - public readonly string _authority; + + /// The authority host with a trailing slash, e.g. "https://login.microsoftonline.com/". + public readonly string _authorityHost; + public readonly string _scope; - public readonly string _audience; + + /// + /// The tenant, which may be a tenant id, a domain name, or one of the + /// `common` / `organizations` / `consumers` placeholders. Empty when the credential + /// type doesn't take a tenant. + /// + public readonly string _tenant; + public readonly string? _clientId; - public TokenCredentialKey(Type tokenCredentialType, string authority, string scope, string audience, string? clientId) + public TokenCredentialKey(Type tokenCredentialType, string authorityHost, string scope, string tenant, string? clientId) { _tokenCredentialType = tokenCredentialType; - _authority = authority; + _authorityHost = authorityHost; _scope = scope; - _audience = audience; + _tenant = tenant; _clientId = clientId; } @@ -1102,15 +1109,15 @@ public override bool Equals(object? obj) if (obj != null && obj is TokenCredentialKey tcKey) { return _tokenCredentialType == tcKey._tokenCredentialType - && string.CompareOrdinal(_authority, tcKey._authority) == 0 + && string.CompareOrdinal(_authorityHost, tcKey._authorityHost) == 0 && string.CompareOrdinal(_scope, tcKey._scope) == 0 - && string.CompareOrdinal(_audience, tcKey._audience) == 0 + && string.CompareOrdinal(_tenant, tcKey._tenant) == 0 && string.CompareOrdinal(_clientId, tcKey._clientId) == 0 ; } return false; } - public override int GetHashCode() => Tuple.Create(_tokenCredentialType, _authority, _scope, _audience, _clientId).GetHashCode(); + public override int GetHashCode() => Tuple.Create(_tokenCredentialType, _authorityHost, _scope, _tenant, _clientId).GetHashCode(); } } diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs index 3d591695a4..3a5d60442d 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs @@ -80,6 +80,12 @@ public class AuthorityParsingTests "organizations", "https://login.microsoftonline.com/organizations" }, + { + "https://login.microsoftonline.com/consumers", + "https://login.microsoftonline.com/", + "consumers", + "https://login.microsoftonline.com/consumers" + }, }; [Theory] From b6d8985f20f196ca5ab24dafd72e553b56e1cd2a Mon Sep 17 00:00:00 2001 From: Cheena Malhotra Date: Tue, 11 Aug 2026 12:17:07 -0700 Subject: [PATCH 4/4] Use Uri.Segments to extract the tenant Address review feedback: let the Uri class handle URI decomposition instead of doing string manipulation on AbsolutePath. Segments[0] is always the leading "/", so the tenant is Segments[1]. Segments retain their trailing separator when further segments follow, so the value is trimmed. The non-empty check is kept to reject an empty leading segment (e.g. "https://host//oauth2/authorize"), which would otherwise yield an authority with no tenant; a test covers this. Also fix a "DefaultAzureCredenial" typo in a comment touched by the previous commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4e906c9-daf3-46f8-83e7-ef805d81fdfb --- .../src/ActiveDirectoryAuthenticationProvider.cs | 12 +++++++----- .../Azure/test/AuthorityParsingTests.cs | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 45cb0a0cbc..2ab9c9d295 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -265,7 +265,7 @@ public override async Task AcquireTokenAsync(SqlAuthenti if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryDefault) { - // Cache DefaultAzureCredenial based on scope, authority host, tenant, and clientId + // Cache DefaultAzureCredential based on scope, authority host, tenant, and clientId TokenCredentialKey tokenCredentialKey = new(typeof(DefaultAzureCredential), authorityHost, scope, tenant, clientId); AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, string.Empty, tokenRequestContext, cts.Token).ConfigureAwait(false); SqlClientEventSource.Log.TryTraceEvent("AcquireTokenAsync | Acquired access token for Default auth mode. Expiry Time: {0}", accessToken.ExpiresOn); @@ -588,11 +588,13 @@ internal static bool TryParseAuthority( out string msalAuthority) { if (Uri.TryCreate(authorityUrl, UriKind.Absolute, out Uri? uri) && - uri.Scheme == Uri.UriSchemeHttps) + uri.Scheme == Uri.UriSchemeHttps && + uri.Segments.Length > 1) { - string path = uri.AbsolutePath.Trim('/'); - int slashIndex = path.IndexOf('/'); - tenant = slashIndex < 0 ? path : path.Substring(0, slashIndex); + // Segments[0] is always the leading "/", so the tenant is Segments[1]. Each segment + // keeps its trailing separator when further segments follow, e.g. the segments of + // "/{tenant}/oauth2/authorize" are [ "/", "{tenant}/", "oauth2/", "authorize" ]. + tenant = uri.Segments[1].TrimEnd('/'); if (tenant.Length > 0) { diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs index 3a5d60442d..e31bc266b8 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs @@ -111,6 +111,8 @@ public void TryParseAuthority_SplitsHostAndTenant( // A tenant is required; an authority without one cannot yield a usable credential. [InlineData("https://login.microsoftonline.com")] [InlineData("https://login.microsoftonline.com/")] + // An empty leading path segment leaves no tenant to authenticate against. + [InlineData("https://login.microsoftonline.com//oauth2/authorize")] // The server may omit the STSURL entirely. [InlineData("")] public void TryParseAuthority_RejectsAuthorityWithoutTenant(string authorityUrl)