diff --git a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs index 52b1da7ba7..45cb0a0cbc 100644 --- a/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/src/ActiveDirectoryAuthenticationProvider.cs @@ -222,43 +222,51 @@ 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: // // 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. + + if (!TryParseAuthority(parameters.Authority, out string authorityHost, out string tenant, 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/'."); + } - int separatorIndex = parameters.Authority.LastIndexOf('/'); - string authority = parameters.Authority.Remove(separatorIndex + 1); - string audience = parameters.Authority.Substring(separatorIndex + 1); string? clientId = string.IsNullOrWhiteSpace(parameters.UserId) ? null : parameters.UserId; 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); @@ -266,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); @@ -275,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); @@ -285,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); @@ -316,9 +324,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; @@ -435,6 +443,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. @@ -533,6 +546,68 @@ 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. + /// + /// + /// 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 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) + { + 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 true; + } + } + + authorityHost = string.Empty; + tenant = string.Empty; + msalAuthority = string.Empty; + return false; + } + private static async Task TryAcquireTokenSilent(IPublicClientApplication app, SqlAuthenticationParameters parameters, string[] scopes, CancellationTokenSource cts) { @@ -864,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. }; @@ -909,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) { @@ -1005,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; } @@ -1024,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 new file mode 100644 index 0000000000..3a5d60442d --- /dev/null +++ b/src/Microsoft.Data.SqlClient.Extensions/Azure/test/AuthorityParsingTests.cs @@ -0,0 +1,128 @@ +// 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 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"; + + 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}" + }, + // v1.0 authorize endpoint, as 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}" + }, + // 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", + "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" + }, + { + "https://login.microsoftonline.com/organizations", + "https://login.microsoftonline.com/", + "organizations", + "https://login.microsoftonline.com/organizations" + }, + { + "https://login.microsoftonline.com/consumers", + "https://login.microsoftonline.com/", + "consumers", + "https://login.microsoftonline.com/consumers" + }, + }; + + [Theory] + [MemberData(nameof(AuthorityData))] + public void TryParseAuthority_SplitsHostAndTenant( + string authorityUrl, + string expectedHost, + string expectedTenant, + string expectedMsalAuthority) + { + Assert.True(ActiveDirectoryAuthenticationProvider.TryParseAuthority( + authorityUrl, + out string host, + out string tenant, + out string msalAuthority)); + + Assert.Equal(expectedHost, host); + Assert.Equal(expectedTenant, tenant); + Assert.Equal(expectedMsalAuthority, msalAuthority); + } + + [Theory] + // 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) + { + Assert.False(ActiveDirectoryAuthenticationProvider.TryParseAuthority( + authorityUrl, + out string host, + out string tenant, + out string msalAuthority)); + + Assert.Equal(string.Empty, host); + Assert.Equal(string.Empty, tenant); + Assert.Equal(string.Empty, msalAuthority); + } +}