-
Notifications
You must be signed in to change notification settings - Fork 331
Fix Entra ID tenant parsing for multi-segment STSURL authorities #4521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e2cce82
8866e3f
e4908ae
b6d8985
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -222,61 +222,69 @@ public override async Task<SqlAuthenticationToken> 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/<tenant>'."); | ||
| } | ||
|
|
||
| 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 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); | ||
| } | ||
|
|
||
| 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); | ||
| 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<SqlAuthenticationToken> 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<SqlAuthenticationToken> 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing tests this bit. It would be worth asserting that |
||
| { | ||
| // Already shaped for the caller; don't re-wrap it below. | ||
| throw; | ||
| } | ||
| catch (MsalException ex) | ||
| { | ||
| // Check for an explicitly retryable error. | ||
|
|
@@ -533,6 +546,70 @@ or AuthenticationRequiredException | |
| } | ||
| } | ||
|
|
||
| /// <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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The authority is now normalized everywhere except |
||
| 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) | ||
| { | ||
|
|
@@ -864,8 +941,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 +986,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 +1082,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 | ||
| /// `common` / `organizations` / `consumers` 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; | ||
| } | ||
|
|
||
|
|
@@ -1024,15 +1111,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(); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.