diff --git a/src/GeneralTools/DataverseClient/Client/ConnectionService.cs b/src/GeneralTools/DataverseClient/Client/ConnectionService.cs index 23c50b6..b9460d5 100644 --- a/src/GeneralTools/DataverseClient/Client/ConnectionService.cs +++ b/src/GeneralTools/DataverseClient/Client/ConnectionService.cs @@ -86,6 +86,7 @@ internal sealed class ConnectionService : IConnectionService, IDisposable private OrganizationWebProxyClientAsync _svcWebClientProxy; private OrganizationServiceProxyAsync _svcOnPremClientProxy; private OrganizationWebProxyClientAsync _externalWebClientProxy; // OAuth specific web service proxy + private readonly object _redirectRecoveryLock = new object(); [NonSerializedAttribute] private WhoAmIResponse user; // Dataverse user entity that is the service. @@ -1848,7 +1849,7 @@ internal void SetClonedProperties(ServiceClient sourceClient) debugingCloneStateFilter++; OrganizationId = sourceClient.ConnectedOrgId; debugingCloneStateFilter++; - _ActualDataverseOrgUri = sourceClient.ConnectedOrgUriActual; + _ActualDataverseOrgUri = sourceClient.CurrentOrganizationServiceUri; debugingCloneStateFilter++; _MsalAuthClient = sourceClient._connectionSvc._MsalAuthClient; debugingCloneStateFilter++; @@ -3757,6 +3758,337 @@ internal async Task RefreshClientTokenAsync() return clientToken; } + internal async Task TryRecoverFromCrossHostRedirectAsync(Exception exception, Uri requestServiceUri, Guid requestId) + { + if (_eAuthType != AuthenticationType.ExternalTokenManagement || GetAccessTokenAsync == null || exception == null) + return false; + + Uri redirectAuthority; + string challenge; + if (!TryGetRedirectAuthority(exception, out redirectAuthority, out challenge)) + return false; + + Uri redirectedServiceUri; + if (!TryCreateTrustedRedirectServiceUri(requestServiceUri, redirectAuthority, out redirectedServiceUri)) + { + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery rejected an untrusted target. RequestId={0}, CurrentAuthority={1}, RedirectAuthority={2}", + requestId, + GetAuthority(requestServiceUri), + GetAuthority(redirectAuthority)), + TraceEventType.Warning); + return false; + } + + bool recoveryAlreadyCompleted; + lock (_redirectRecoveryLock) + { + recoveryAlreadyCompleted = string.Equals( + GetAuthority(_ActualDataverseOrgUri), + GetAuthority(redirectedServiceUri), + StringComparison.OrdinalIgnoreCase); + } + + if (recoveryAlreadyCompleted) + { + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery already completed by another request. RequestId={0}, Authority={1}", + requestId, + GetAuthority(redirectedServiceUri)), + TraceEventType.Information); + return true; + } + + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery detected. RequestId={0}, CurrentAuthority={1}, RedirectAuthority={2}, ChallengeResource={3}", + requestId, + GetAuthority(requestServiceUri), + GetAuthority(redirectedServiceUri), + GetChallengeResourceAuthority(challenge)), + TraceEventType.Warning); + + string redirectedToken; + try + { + redirectedToken = await GetAccessTokenAsync(redirectedServiceUri.ToString()).ConfigureAwait(false); + if (string.IsNullOrEmpty(redirectedToken)) + { + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery token provider returned an empty token. RequestId={0}, RedirectAuthority={1}", + requestId, + GetAuthority(redirectedServiceUri)), + TraceEventType.Error); + return false; + } + } + catch (Exception tokenException) + { + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery token acquisition failed. RequestId={0}, RedirectAuthority={1}", + requestId, + GetAuthority(redirectedServiceUri)), + TraceEventType.Error, + tokenException); + return false; + } + + OrganizationWebProxyClientAsync replacementProxy = null; + OrganizationWebProxyClientAsync previousProxy = null; + try + { + lock (_redirectRecoveryLock) + { + if (string.Equals(GetAuthority(_ActualDataverseOrgUri), GetAuthority(redirectedServiceUri), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + previousProxy = _svcWebClientProxy ?? _externalWebClientProxy; + replacementProxy = CreateRedirectedWebProxy(previousProxy, redirectedServiceUri, redirectedToken); + + _svcWebClientProxy = replacementProxy; + if (UseExternalConnection) + _externalWebClientProxy = replacementProxy; + + _ActualDataverseOrgUri = redirectedServiceUri; + _targetInstanceUriToConnectTo = redirectedServiceUri; + _hostname = redirectedServiceUri.Host; + replacementProxy = null; + } + + DisposeWebProxy(previousProxy); + + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery updated the SOAP endpoint. RequestId={0}, ConnectedAuthority={1}", + requestId, + GetAuthority(_ActualDataverseOrgUri)), + TraceEventType.Information); + return true; + } + catch (Exception recoveryException) + { + DisposeWebProxy(replacementProxy); + logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Cross-host redirect recovery failed while replacing the SOAP endpoint. RequestId={0}, RedirectAuthority={1}", + requestId, + GetAuthority(redirectedServiceUri)), + TraceEventType.Error, + recoveryException); + return false; + } + } + + internal static bool TryCreateTrustedRedirectServiceUri(Uri currentServiceUri, Uri redirectUri, out Uri redirectedServiceUri) + { + redirectedServiceUri = null; + if (currentServiceUri == null || redirectUri == null || + !string.Equals(currentServiceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.Equals(redirectUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(currentServiceUri.UserInfo) || + !string.IsNullOrEmpty(redirectUri.UserInfo) || + currentServiceUri.Port != redirectUri.Port) + return false; + + string currentHost = currentServiceUri.DnsSafeHost; + string redirectHost = redirectUri.DnsSafeHost; + if (string.Equals(currentHost, redirectHost, StringComparison.OrdinalIgnoreCase)) + return false; + + int currentSeparator = currentHost.IndexOf('.'); + int redirectSeparator = redirectHost.IndexOf('.'); + if (currentSeparator <= 0 || redirectSeparator <= 0) + return false; + + string currentSuffix = currentHost.Substring(currentSeparator); + string redirectSuffix = redirectHost.Substring(redirectSeparator); + if (!string.Equals(currentSuffix, redirectSuffix, StringComparison.OrdinalIgnoreCase)) + return false; + + bool currentIsRouted; + bool redirectIsRouted; + string currentOrganization = GetCanonicalOrganizationName(currentHost.Substring(0, currentSeparator), out currentIsRouted); + string redirectOrganization = GetCanonicalOrganizationName(redirectHost.Substring(0, redirectSeparator), out redirectIsRouted); + if (currentIsRouted == redirectIsRouted || + !string.Equals(currentOrganization, redirectOrganization, StringComparison.OrdinalIgnoreCase)) + return false; + + UriBuilder redirectedBuilder = new UriBuilder(currentServiceUri) + { + Scheme = redirectUri.Scheme, + Host = redirectUri.Host, + Port = redirectUri.IsDefaultPort ? -1 : redirectUri.Port + }; + redirectedServiceUri = redirectedBuilder.Uri; + return true; + } + + internal static bool TryGetRedirectResourceUri(string challenge, out Uri resourceUri) + { + resourceUri = null; + if (string.IsNullOrWhiteSpace(challenge)) + return false; + + const string resourceKey = "resource_id="; + int resourceIndex = 0; + while (true) + { + resourceIndex = challenge.IndexOf(resourceKey, resourceIndex, StringComparison.OrdinalIgnoreCase); + if (resourceIndex < 0) + return false; + + if (resourceIndex == 0 || + challenge[resourceIndex - 1] == ',' || + char.IsWhiteSpace(challenge[resourceIndex - 1])) + break; + + resourceIndex += resourceKey.Length; + } + + string resourceValue = challenge.Substring(resourceIndex + resourceKey.Length).TrimStart(); + if (resourceValue.Length == 0) + return false; + + if (resourceValue[0] == '"' || resourceValue[0] == '\'') + { + char quote = resourceValue[0]; + int closingQuote = resourceValue.IndexOf(quote, 1); + if (closingQuote < 0) + return false; + + resourceValue = resourceValue.Substring(1, closingQuote - 1); + } + else + { + int terminator = resourceValue.IndexOfAny(new[] { ',', ' ', '\t', '\r', '\n' }); + if (terminator >= 0) + resourceValue = resourceValue.Substring(0, terminator); + } + + return Uri.TryCreate(resourceValue, UriKind.Absolute, out resourceUri); + } + + private static bool TryGetRedirectAuthority(Exception exception, out Uri redirectAuthority, out string challenge) + { + redirectAuthority = null; + challenge = null; + + for (Exception current = exception; current != null; current = current.InnerException) + { + WebException webException = current as WebException; + HttpWebResponse response = webException?.Response as HttpWebResponse; + if (response != null && response.StatusCode == HttpStatusCode.Unauthorized) + { + challenge = response.Headers[HttpResponseHeader.WwwAuthenticate]; + if (response.ResponseUri != null) + { + redirectAuthority = response.ResponseUri; + return true; + } + + if (TryGetRedirectResourceUri(challenge, out redirectAuthority)) + return true; + } + + if (current is MessageSecurityException && + TryGetRedirectResourceUri(current.Message, out redirectAuthority)) + { + challenge = current.Message; + return true; + } + } + + return false; + } + + private static string GetCanonicalOrganizationName(string organizationHost, out bool isRouted) + { + const string routingSuffix = "--d"; + isRouted = organizationHost.EndsWith(routingSuffix, StringComparison.OrdinalIgnoreCase); + return isRouted + ? organizationHost.Substring(0, organizationHost.Length - routingSuffix.Length) + : organizationHost; + } + + private static string GetAuthority(Uri uri) + { + return uri == null ? string.Empty : uri.GetLeftPart(UriPartial.Authority); + } + + private static string GetChallengeResourceAuthority(string challenge) + { + Uri resourceUri; + return TryGetRedirectResourceUri(challenge, out resourceUri) ? GetAuthority(resourceUri) : string.Empty; + } + + private OrganizationWebProxyClientAsync CreateRedirectedWebProxy( + OrganizationWebProxyClientAsync source, + Uri serviceUri, + string accessToken) + { + OrganizationWebProxyClientAsync destination = null; + try + { + destination = source?.StrongTypeAssembly == null + ? new OrganizationWebProxyClientAsync(serviceUri, _MaxConnectionTimeout, source?.UsesStrongTypes ?? true) + : new OrganizationWebProxyClientAsync(serviceUri, _MaxConnectionTimeout, source.StrongTypeAssembly); + + destination.HeaderToken = accessToken; + CopyWebProxySettings(source, destination); + AttachWebProxyHander(destination); + destination.InnerChannel.OperationTimeout = _MaxConnectionTimeout; + return destination; + } + catch + { + DisposeWebProxy(destination); + throw; + } + } + + private static void CopyWebProxySettings(OrganizationWebProxyClientAsync source, OrganizationWebProxyClientAsync destination) + { + if (source == null || destination == null) + return; + + destination.CallerId = source.CallerId; + destination.CallerRegardingObjectId = source.CallerRegardingObjectId; + destination.ClientAppName = source.ClientAppName; + destination.ClientAppVersion = source.ClientAppVersion; + destination.LanguageCodeOverride = source.LanguageCodeOverride; + destination.OfflinePlayback = source.OfflinePlayback; + destination.SdkClientVersion = source.SdkClientVersion; + destination.SyncOperationType = source.SyncOperationType; + destination.userType = source.userType; + } + + private static void DisposeWebProxy(OrganizationWebProxyClientAsync proxy) + { + if (proxy == null) + return; + + try + { + proxy.Dispose(); + } + catch + { + } + } + #region IDisposable Support ///// ///// Reset disposed state to handle this object being pulled from cache. diff --git a/src/GeneralTools/DataverseClient/Client/Connector/WebProxyClient.cs b/src/GeneralTools/DataverseClient/Client/Connector/WebProxyClient.cs index 24f069f..e1ee281 100644 --- a/src/GeneralTools/DataverseClient/Client/Connector/WebProxyClient.cs +++ b/src/GeneralTools/DataverseClient/Client/Connector/WebProxyClient.cs @@ -21,21 +21,27 @@ internal abstract class WebProxyClientAsync : ClientBase, ID protected WebProxyClientAsync(Uri serviceUrl, bool useStrongTypes) : base(CreateServiceEndpoint(serviceUrl, useStrongTypes, Utilites.DefaultTimeout, null)) { + UsesStrongTypes = useStrongTypes; } protected WebProxyClientAsync(Uri serviceUrl, Assembly strongTypeAssembly) : base(CreateServiceEndpoint(serviceUrl, true, Utilites.DefaultTimeout, strongTypeAssembly)) { + UsesStrongTypes = true; + StrongTypeAssembly = strongTypeAssembly; } protected WebProxyClientAsync(Uri serviceUrl, TimeSpan timeout, bool useStrongTypes) : base(CreateServiceEndpoint(serviceUrl, useStrongTypes, timeout, null)) { + UsesStrongTypes = useStrongTypes; } protected WebProxyClientAsync(Uri serviceUrl, TimeSpan timeout, Assembly strongTypeAssembly) : base(CreateServiceEndpoint(serviceUrl, true, timeout, strongTypeAssembly)) { + UsesStrongTypes = true; + StrongTypeAssembly = strongTypeAssembly; } #region Properties @@ -48,6 +54,10 @@ protected WebProxyClientAsync(Uri serviceUrl, TimeSpan timeout, Assembly strongT internal string ClientAppVersion { get; set; } + internal bool UsesStrongTypes { get; } + + internal Assembly StrongTypeAssembly { get; } + #endregion #region Protected Methods diff --git a/src/GeneralTools/DataverseClient/Client/ServiceClient.cs b/src/GeneralTools/DataverseClient/Client/ServiceClient.cs index e57aa16..0eb4379 100644 --- a/src/GeneralTools/DataverseClient/Client/ServiceClient.cs +++ b/src/GeneralTools/DataverseClient/Client/ServiceClient.cs @@ -89,6 +89,11 @@ public class ServiceClient : IOrganizationService, IOrganizationServiceAsync2, I /// internal object _cloneLockObject = new object(); + /// + /// Shares a redirected organization endpoint across a root ServiceClient and all of its clones. + /// + private RedirectEndpointState _redirectEndpointState = new RedirectEndpointState(); + /// /// BatchManager for Execute Multiple. /// @@ -438,6 +443,19 @@ internal WhoAmIResponse SystemUser /// public Uri ConnectedOrgUriActual { get { if (_connectionSvc != null) return _connectionSvc.ConnectOrgUriActual; else return null; } } + /// + /// Gets the endpoint preferred for new clones in this ServiceClient family. + /// + internal Uri CurrentOrganizationServiceUri + { + get + { + return _connectionSvc == null + ? null + : _redirectEndpointState.GetCurrent(_connectionSvc.ConnectOrgUriActual); + } + } + /// /// Returns the friendly name of the connected Dataverse instance. /// @@ -1384,12 +1402,13 @@ public ServiceClient Clone(System.Reflection.Assembly strongTypeAsm, ILogger log try { OrganizationWebProxyClientAsync proxy = null; - if (_connectionSvc.ConnectOrgUriActual != null) + Uri connectedOrgUri = CurrentOrganizationServiceUri; + if (connectedOrgUri != null) { if (strongTypeAsm == null) - proxy = new OrganizationWebProxyClientAsync(_connectionSvc.ConnectOrgUriActual, true); + proxy = new OrganizationWebProxyClientAsync(connectedOrgUri, true); else - proxy = new OrganizationWebProxyClientAsync(_connectionSvc.ConnectOrgUriActual, strongTypeAsm); + proxy = new OrganizationWebProxyClientAsync(connectedOrgUri, strongTypeAsm); } else { @@ -1421,6 +1440,7 @@ public ServiceClient Clone(System.Reflection.Assembly strongTypeAsm, ILogger log { proxy.HeaderToken = this.CurrentAccessToken; var SvcClient = new ServiceClient(proxy, true, _connectionSvc.AuthenticationTypeInUse, _connectionSvc?.OrganizationVersion, logger: logger); + SvcClient._redirectEndpointState = _redirectEndpointState; SvcClient._connectionSvc.SetClonedProperties(this); SvcClient.CallerAADObjectId = CallerAADObjectId; SvcClient.CallerId = CallerId; @@ -1828,9 +1848,11 @@ internal async Task Command_ExecuteAsyncImpl(OrganizationR TimeSpan LockWait = TimeSpan.Zero; int retryCount = 0; bool retry = false; + bool crossHostRedirectRecoveryAttempted = false; do { + Uri requestServiceUri = _connectionSvc.ConnectOrgUriActual; try { cancellationToken.ThrowIfCancellationRequested(); @@ -1886,23 +1908,42 @@ internal async Task Command_ExecuteAsyncImpl(OrganizationR catch (Exception ex) { bool isThrottled = false; - retry = ShouldRetry(req, ex, retryCount, out isThrottled) && !cancellationToken.IsCancellationRequested; - if (retry) + bool redirectRecovered = !crossHostRedirectRecoveryAttempted && + !cancellationToken.IsCancellationRequested && + await _connectionSvc.TryRecoverFromCrossHostRedirectAsync(ex, requestServiceUri, requestTrackingId).ConfigureAwait(false); + if (redirectRecovered) { - retryCount = await Utilities.RetryRequest(req, requestTrackingId, LockWait, logDt, _logEntry, SessionTrackingId, _disableConnectionLocking, _retryPauseTimeRunning, ex, errorStringCheck, retryCount, isThrottled, cancellationToken: cancellationToken).ConfigureAwait(false); + crossHostRedirectRecoveryAttempted = true; + _redirectEndpointState.Update(_connectionSvc.ConnectOrgUriActual); + retry = true; + _logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Retrying request after cross-host redirect recovery. RequestID={0}, ConnectedAuthority={1}", + requestTrackingId, + ConnectedOrgUriActual?.GetLeftPart(UriPartial.Authority)), + TraceEventType.Information); } else { - _logEntry.LogRetry(retryCount, req, _retryPauseTimeRunning, true, isThrottled: isThrottled); - _logEntry.LogException(req, ex, errorStringCheck); - //keep it in end so that LastError could be a better message. - _logEntry.LogFailure(req, requestTrackingId, SessionTrackingId, _disableConnectionLocking, LockWait, logDt, ex, errorStringCheck, true); - - // Callers which cancel should expect to handle a OperationCanceledException - if (ex is OperationCanceledException) - throw; + retry = ShouldRetry(req, ex, retryCount, out isThrottled) && !cancellationToken.IsCancellationRequested; + if (retry) + { + retryCount = await Utilities.RetryRequest(req, requestTrackingId, LockWait, logDt, _logEntry, SessionTrackingId, _disableConnectionLocking, _retryPauseTimeRunning, ex, errorStringCheck, retryCount, isThrottled, cancellationToken: cancellationToken).ConfigureAwait(false); + } else - cancellationToken.ThrowIfCancellationRequested(); + { + _logEntry.LogRetry(retryCount, req, _retryPauseTimeRunning, true, isThrottled: isThrottled); + _logEntry.LogException(req, ex, errorStringCheck); + //keep it in end so that LastError could be a better message. + _logEntry.LogFailure(req, requestTrackingId, SessionTrackingId, _disableConnectionLocking, LockWait, logDt, ex, errorStringCheck, true); + + // Callers which cancel should expect to handle a OperationCanceledException + if (ex is OperationCanceledException) + throw; + else + cancellationToken.ThrowIfCancellationRequested(); + } } resp = null; } @@ -1931,9 +1972,11 @@ internal OrganizationResponse Command_Execute(OrganizationRequest req, string er TimeSpan LockWait = TimeSpan.Zero; int retryCount = 0; bool retry = false; + bool crossHostRedirectRecoveryAttempted = false; do { + Uri requestServiceUri = _connectionSvc.ConnectOrgUriActual; try { _retryPauseTimeRunning = _configuration.Value.RetryPauseTime; // Set the default time for each loop. @@ -1999,20 +2042,38 @@ internal OrganizationResponse Command_Execute(OrganizationRequest req, string er catch (Exception ex) { bool isThrottled = false; - retry = ShouldRetry(req, ex, retryCount, out isThrottled); - if (retry) + bool redirectRecovered = !crossHostRedirectRecoveryAttempted && + _connectionSvc.TryRecoverFromCrossHostRedirectAsync(ex, requestServiceUri, requestTrackingId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (redirectRecovered) { - Task.Run(async () => - { - retryCount = await Utilities.RetryRequest(req, requestTrackingId, LockWait, logDt, _logEntry, SessionTrackingId, _disableConnectionLocking, _retryPauseTimeRunning, ex, errorStringCheck, retryCount, isThrottled).ConfigureAwait(false); - }).ConfigureAwait(false).GetAwaiter().GetResult(); + crossHostRedirectRecoveryAttempted = true; + _redirectEndpointState.Update(_connectionSvc.ConnectOrgUriActual); + retry = true; + _logEntry.Log( + string.Format( + CultureInfo.InvariantCulture, + "Retrying request after cross-host redirect recovery. RequestID={0}, ConnectedAuthority={1}", + requestTrackingId, + ConnectedOrgUriActual?.GetLeftPart(UriPartial.Authority)), + TraceEventType.Information); } else { - _logEntry.LogRetry(retryCount, req, _retryPauseTimeRunning, true, isThrottled: isThrottled); - _logEntry.LogException(req, ex, errorStringCheck); - //keep it in end so that LastError could be a better message. - _logEntry.LogFailure(req, requestTrackingId, SessionTrackingId, _disableConnectionLocking, LockWait, logDt, ex, errorStringCheck, true); + retry = ShouldRetry(req, ex, retryCount, out isThrottled); + if (retry) + { + Task.Run(async () => + { + retryCount = await Utilities.RetryRequest(req, requestTrackingId, LockWait, logDt, _logEntry, SessionTrackingId, _disableConnectionLocking, _retryPauseTimeRunning, ex, errorStringCheck, retryCount, isThrottled).ConfigureAwait(false); + }).ConfigureAwait(false).GetAwaiter().GetResult(); + } + else + { + _logEntry.LogRetry(retryCount, req, _retryPauseTimeRunning, true, isThrottled: isThrottled); + _logEntry.LogException(req, ex, errorStringCheck); + //keep it in end so that LastError could be a better message. + _logEntry.LogFailure(req, requestTrackingId, SessionTrackingId, _disableConnectionLocking, LockWait, logDt, ex, errorStringCheck, true); + } } resp = null; } @@ -2112,7 +2173,33 @@ public static SecureString MakeSecureString(string pass) } return null; } - + + private sealed class RedirectEndpointState + { + private readonly object syncRoot = new object(); + + private Uri current; + + public Uri GetCurrent(Uri fallback) + { + lock (syncRoot) + { + return current ?? fallback; + } + } + + public void Update(Uri serviceUri) + { + if (serviceUri == null) + return; + + lock (syncRoot) + { + current = serviceUri; + } + } + } + /// /// Validates that a connection is live and connected. Throws an exception if the connection is not active. /// diff --git a/src/GeneralTools/DataverseClient/UnitTests/CdsClient_Core_Tests/ServiceClientTests.cs b/src/GeneralTools/DataverseClient/UnitTests/CdsClient_Core_Tests/ServiceClientTests.cs index 790a214..9b7f759 100644 --- a/src/GeneralTools/DataverseClient/UnitTests/CdsClient_Core_Tests/ServiceClientTests.cs +++ b/src/GeneralTools/DataverseClient/UnitTests/CdsClient_Core_Tests/ServiceClientTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.PowerPlatform.Dataverse.Client.Auth; +using Microsoft.PowerPlatform.Dataverse.Client.Connector; using Microsoft.PowerPlatform.Dataverse.Client.Exceptions; using Microsoft.PowerPlatform.Dataverse.Client.Extensions; using Microsoft.PowerPlatform.Dataverse.Client.HttpUtils; @@ -24,8 +25,10 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Reflection; using System.Runtime.CompilerServices; using System.Security; +using System.ServiceModel.Security; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -169,6 +172,174 @@ public void LogWriteTest() } + [Fact] + public void CrossHostRedirect_CreatesTrustedTemporaryServiceUri() + { + var currentUri = new Uri("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web?SDKClientVersion=1.2.0.2"); + var responseUri = new Uri("https://contoso--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + + Uri redirectedUri; + var result = ConnectionService.TryCreateTrustedRedirectServiceUri(currentUri, responseUri, out redirectedUri); + + Assert.True(result); + Assert.Equal("contoso--d.crm.dynamics.com", redirectedUri.Host); + Assert.Equal(currentUri.AbsolutePath, redirectedUri.AbsolutePath); + Assert.Equal(currentUri.Query, redirectedUri.Query); + } + + [Fact] + public void CrossHostRedirect_CreatesTrustedFailbackServiceUri() + { + var currentUri = new Uri("https://contoso--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + var responseUri = new Uri("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + + Uri redirectedUri; + var result = ConnectionService.TryCreateTrustedRedirectServiceUri(currentUri, responseUri, out redirectedUri); + + Assert.True(result); + Assert.Equal("contoso.crm.dynamics.com", redirectedUri.Host); + } + + [Theory] + [InlineData("https://evil.example.com/XRMServices/2011/Organization.svc/web")] + [InlineData("https://otherorg--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web")] + [InlineData("http://contoso--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web")] + [InlineData("https://contoso--d.crm.dynamics.com:444/XRMServices/2011/Organization.svc/web")] + [InlineData("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web")] + [InlineData("https://contoso--evil.crm.dynamics.com/XRMServices/2011/Organization.svc/web")] + [InlineData("https://user@contoso--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web")] + public void CrossHostRedirect_RejectsUntrustedOrSameHostTargets(string responseUrl) + { + var currentUri = new Uri("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + + Uri redirectedUri; + var result = ConnectionService.TryCreateTrustedRedirectServiceUri(currentUri, new Uri(responseUrl), out redirectedUri); + + Assert.False(result); + Assert.Null(redirectedUri); + } + + [Fact] + public void CrossHostRedirect_RejectsCurrentUriWithUserInfo() + { + var currentUri = new Uri("https://user@contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + var responseUri = new Uri("https://contoso--d.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + + Uri redirectedUri; + var result = ConnectionService.TryCreateTrustedRedirectServiceUri(currentUri, responseUri, out redirectedUri); + + Assert.False(result); + Assert.Null(redirectedUri); + } + + [Fact] + public void CrossHostRedirect_ParsesChallengeResource() + { + const string challenge = + "Bearer authorization_uri=\"https://login.microsoftonline.com/tenant\", resource_id=\"https://contoso--d.crm.dynamics.com/\""; + + Uri resourceUri; + var result = ConnectionService.TryGetRedirectResourceUri(challenge, out resourceUri); + + Assert.True(result); + Assert.Equal("https://contoso--d.crm.dynamics.com/", resourceUri.AbsoluteUri); + } + + [Fact] + public void CrossHostRedirect_RejectsEmbeddedChallengeResourceKey() + { + const string challenge = + "Bearer notresource_id=\"https://contoso--d.crm.dynamics.com/\""; + + Uri resourceUri; + var result = ConnectionService.TryGetRedirectResourceUri(challenge, out resourceUri); + + Assert.False(result); + Assert.Null(resourceUri); + } + + [Fact] + public async Task CrossHostRedirect_RecoveryReplacesProxyAndRequestsRedirectToken() + { + var currentUri = new Uri("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + var strongTypeAssembly = typeof(ServiceClientTests).Assembly; + var originalProxy = new OrganizationWebProxyClientAsync(currentUri, strongTypeAssembly); + var logger = new DataverseTraceLogger(Ilogger); + var connection = new ConnectionService( + originalProxy, + Microsoft.PowerPlatform.Dataverse.Client.AuthenticationType.ExternalTokenManagement, + logger, + true); + SetConnectionServiceUri(connection, currentUri); + + string requestedTokenUri = null; + connection.GetAccessTokenAsync = targetUri => + { + requestedTokenUri = targetUri; + return Task.FromResult("redirect-token"); + }; + + try + { + var exception = new MessageSecurityException( + "The HTTP request is unauthorized. resource_id=https://contoso--d.crm.dynamics.com/"); + var result = await connection.TryRecoverFromCrossHostRedirectAsync( + exception, + currentUri, + Guid.NewGuid()).ConfigureAwait(false); + + Assert.True(result); + Assert.Equal("contoso--d.crm.dynamics.com", connection.ConnectOrgUriActual.Host); + Assert.Equal("contoso--d.crm.dynamics.com", new Uri(requestedTokenUri).Host); + Assert.Equal("contoso--d.crm.dynamics.com", connection.WebClient.Endpoint.Address.Uri.Host); + Assert.Equal("redirect-token", connection.WebClient.HeaderToken); + Assert.Same(strongTypeAssembly, connection.WebClient.StrongTypeAssembly); + } + finally + { + connection.Dispose(); + } + } + + [Fact] + public async Task CrossHostRedirect_RecoveryRejectsDifferentOrganization() + { + var currentUri = new Uri("https://contoso.crm.dynamics.com/XRMServices/2011/Organization.svc/web"); + var originalProxy = new OrganizationWebProxyClientAsync(currentUri, true); + var logger = new DataverseTraceLogger(Ilogger); + var connection = new ConnectionService( + originalProxy, + Microsoft.PowerPlatform.Dataverse.Client.AuthenticationType.ExternalTokenManagement, + logger, + true); + SetConnectionServiceUri(connection, currentUri); + + var tokenRequested = false; + connection.GetAccessTokenAsync = targetUri => + { + tokenRequested = true; + return Task.FromResult("redirect-token"); + }; + + try + { + var exception = new MessageSecurityException( + "The HTTP request is unauthorized. resource_id=https://otherorg--d.crm.dynamics.com/"); + var result = await connection.TryRecoverFromCrossHostRedirectAsync( + exception, + currentUri, + Guid.NewGuid()).ConfigureAwait(false); + + Assert.False(result); + Assert.False(tokenRequested); + Assert.Equal("contoso.crm.dynamics.com", connection.ConnectOrgUriActual.Host); + } + finally + { + connection.Dispose(); + } + } + [Fact] public void DeleteRequestTests() { @@ -1927,6 +2098,15 @@ private ServiceClient CreateServiceClient() return client; } + private static void SetConnectionServiceUri(ConnectionService connection, Uri serviceUri) + { + var field = typeof(ConnectionService).GetField( + "_ActualDataverseOrgUri", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + field.SetValue(connection, serviceUri); + } + private void WaitForAsyncOperationToComplete(Stopwatch _HoldTime, Stopwatch _RunTime, ServiceClient client, Guid? asyncTrackingId) { if (asyncTrackingId != null && asyncTrackingId != Guid.Empty) diff --git a/src/nuspecs/Microsoft.PowerPlatform.Dataverse.Client.ReleaseNotes.txt b/src/nuspecs/Microsoft.PowerPlatform.Dataverse.Client.ReleaseNotes.txt index a6fc7ef..8bde5dd 100644 --- a/src/nuspecs/Microsoft.PowerPlatform.Dataverse.Client.ReleaseNotes.txt +++ b/src/nuspecs/Microsoft.PowerPlatform.Dataverse.Client.ReleaseNotes.txt @@ -7,6 +7,7 @@ Notice: Note: Only AD on FullFramework, OAuth, Certificate, ClientSecret Authentication types are supported at this time. ++CURRENTRELEASEID++ +Recover external-token SOAP connections when Dataverse BCDR redirects between the canonical and trusted temporary endpoint. Add a new configuration option UseExponentialRetryDelayForConcurrencyThrottle to use exponential retry delay for concurrency throttling, instead of the server-specified Retry-After header where applicable. Default is False. 1.2.7 @@ -468,4 +469,3 @@ Intial Alpha release of Microsoft.Cds.Client.CdsServiceClient This library removes several Dynamics specific helper methods from CrmServiceClient. this additional methods can be found by include the nuget package Microsoft.Cds.Client.Dynamics -