Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,59 +192,67 @@ public override async Task<SqlAuthenticationToken> AcquireTokenAsync(SqlAuthenti
string[] scopes = new string[] { scope };
TokenRequestContext tokenRequestContext = new(scopes);

/* We split audience from Authority URL here. Audience can be one of the following:
* The Azure AD authority audience enumeration
/* We split the tenant from the Authority URL here. The tenant can be one of the following:
* The tenant ID, which can be:
* - A GUID (the ID of your Azure AD instance), for single-tenant applications
* - A domain name associated with your Azure AD instance (also for single-tenant applications)
* One of these placeholders as a tenant ID in place of the Azure AD authority audience enumeration:
* One of these placeholders, which select an Azure AD 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 Azure AD authority audience and the tenant ID.
* If you don't specify an audience, your app will target Azure AD and personal Microsoft accounts as an audience. (That is, it will behave as though `common` were specified.)
* More information: https://docs.microsoft.com/azure/active-directory/develop/msal-client-application-configuration
* If no tenant is specified, the app targets Azure AD 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://learn.microsoft.com/entra/identity-platform/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);
if (!TryParseAuthority(parameters.Authority, out string authorityHost, out string tenant, out string msalAuthority))
{
throw new ArgumentException(
Comment thread
cheenamalhotra marked this conversation as resolved.
StringsHelper.GetString(Strings.SQL_InvalidAuthority, parameters.Authority),
nameof(parameters.Authority));
}

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 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);
return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn);
}

TokenCredentialOptions tokenCredentialOptions = new() { AuthorityHost = new Uri(authority) };
TokenCredentialOptions tokenCredentialOptions = new() { AuthorityHost = new Uri(authorityHost) };

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);
}

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);
AccessToken accessToken = await GetTokenAsync(tokenCredentialKey, parameters.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);
return new SqlAuthenticationToken(accessToken.Token, accessToken.ExpiresOn);
}

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);
Expand Down Expand Up @@ -280,7 +288,7 @@ public override async Task<SqlAuthenticationToken> AcquireTokenAsync(SqlAuthenti
{
redirectUri = s_systemBrowserRedirectUri;
}
PublicClientAppKey pcaKey = new(parameters.Authority, redirectUri, _applicationClientId
PublicClientAppKey pcaKey = new(msalAuthority, redirectUri, _applicationClientId
#if NETFRAMEWORK
, _iWin32WindowFunc
#endif
Expand Down Expand Up @@ -321,7 +329,7 @@ public override async Task<SqlAuthenticationToken> AcquireTokenAsync(SqlAuthenti
}
else if (parameters.AuthenticationMethod == SqlAuthenticationMethod.ActiveDirectoryPassword)
{
string pwCacheKey = GetAccountPwCacheKey(parameters);
string pwCacheKey = GetAccountPwCacheKey(msalAuthority, parameters.UserId);
object previousPw = s_accountPwCache.Get(pwCacheKey);
byte[] currPwHash = GetHash(parameters.Password);

Expand Down Expand Up @@ -387,6 +395,70 @@ previousPw is byte[] previousPwBytes &&
return new SqlAuthenticationToken(result.AccessToken, result.ExpiresOn);
}

/// <summary>
/// Splits an Entra ID authority URL (the STSURL provided by the server in the FEDAUTHINFO TDS
/// token) into the authority host and the tenant.
/// </summary>
/// <param name="authorityUrl">
/// The authority URL, e.g. <c>https://login.microsoftonline.com/{tenantId}</c>. Some services
/// (for example the Dataverse/Dynamics 365 TDS endpoint) return an ADAL v1 style URL such as
/// <c>https://login.microsoftonline.com/{tenantId}/oauth2/authorize</c>.
/// </param>
/// <param name="authorityHost">
/// Receives the authority host with a trailing slash, e.g. <c>https://login.microsoftonline.com/</c>.
/// </param>
/// <param name="tenant">
/// Receives the tenant (the first path segment of the authority URL), which may be a tenant id,
/// a domain name, or one of the <c>common</c>/<c>organizations</c>/<c>consumers</c> placeholders.
/// </param>
/// <param name="msalAuthority">
/// Receives the normalized authority (host + tenant) suitable for MSAL's <c>WithAuthority</c>.
/// </param>
/// <returns>
/// <c>true</c> if the authority URL is a well-formed, absolute HTTPS URL carrying a tenant
/// segment; otherwise <c>false</c>.
/// </returns>
/// <remarks>
/// <para>
/// The tenant is taken from the first path segment rather than the last so that trailing
/// endpoint suffixes (<c>/oauth2/authorize</c>, <c>/oauth2/v2.0/token</c>, etc.) are ignored.
/// </para>
/// <para>
/// Entra ID authorities are always absolute HTTPS URLs, so anything else is rejected rather
/// than guessed at. Both MSAL (<c>WithAuthority</c>) and Azure.Identity
/// (<c>TokenCredentialOptions.AuthorityHost</c>) require an absolute URI as well, so an
/// unparseable authority cannot produce a working credential.
/// </para>
/// </remarks>
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.Segments.Length > 1)
{
// 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)
{
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<AuthenticationResult> TryAcquireTokenSilent(IPublicClientApplication app, SqlAuthenticationParameters parameters,
string[] scopes, CancellationTokenSource cts)
{
Expand Down Expand Up @@ -601,9 +673,18 @@ private static async Task<AccessToken> GetTokenAsync(TokenCredentialKey tokenCre
return await tokenCredentialInstance._tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken);
}

private static string GetAccountPwCacheKey(SqlAuthenticationParameters parameters)
/// <summary>
/// Builds the cache key used to remember which password was last validated for an account.
/// </summary>
/// <param name="msalAuthority">
/// The normalized authority (host + tenant) from <see cref="TryParseAuthority"/>. The
/// normalized form is used so that two spellings of the same tenant (for example a bare
/// tenant endpoint and an OAuth v1 <c>/oauth2/authorize</c> endpoint) share a single entry.
/// </param>
/// <param name="userId">The user id being authenticated, which may be null.</param>
private static string GetAccountPwCacheKey(string msalAuthority, string userId)
{
return parameters.Authority + "+" + parameters.UserId;
return msalAuthority + "+" + userId;
}

private static byte[] GetHash(string input)
Expand Down Expand Up @@ -692,8 +773,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.
};

Expand Down Expand Up @@ -737,23 +818,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)
{
Expand Down Expand Up @@ -827,17 +908,27 @@ public TokenCredentialData(TokenCredential tokenCredential, byte[] secretHash)
internal class TokenCredentialKey
{
public readonly Type _tokenCredentialType;
public readonly string _authority;

/// <summary>The authority host with a trailing slash, e.g. "https://login.microsoftonline.com/".</summary>
public readonly string _authorityHost;

public readonly string _scope;
public readonly string _audience;

/// <summary>
/// The tenant, which may be a tenant id, a domain name, or one of the
/// <c>common</c> / <c>organizations</c> / <c>consumers</c> placeholders. Empty when the credential
/// type doesn't take a tenant.
/// </summary>
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;
}

Expand All @@ -846,16 +937,16 @@ public override bool Equals(object obj)
if (obj != null && obj is TokenCredentialKey tcKey)
{
return string.CompareOrdinal(nameof(_tokenCredentialType), nameof(tcKey._tokenCredentialType)) == 0
&& 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
;
}
Comment thread
cheenamalhotra marked this conversation as resolved.
Comment thread
cheenamalhotra marked this conversation as resolved.
Comment thread
cheenamalhotra marked this conversation as resolved.
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();
}

}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Microsoft.Data.SqlClient/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -2574,6 +2574,9 @@
<data name="SQL_UnsupportedAuthentication" xml:space="preserve">
<value>The authentication '{0}' is not supported.</value>
</data>
<data name="SQL_InvalidAuthority" xml:space="preserve">
<value>The authority '{0}' is not a valid Entra ID authority. Expected an absolute HTTPS URL containing a tenant, e.g. 'https://login.microsoftonline.com/&lt;tenant&gt;'.</value>
</data>
<data name="SQL_UnsupportedSqlAuthenticationMethod" xml:space="preserve">
<value>SQL authentication method '{0}' is not supported.</value>
</data>
Expand Down
Loading
Loading