Open Closed

Maui Blazor (Hybrid) App Can't Login #7908


User avatar
0
DWaterfield created

We're creating a Maui Blazor (Hybrid) app via ABP Studio. The application when run, cannot Login, it just hangs with the darkened overlay screen and does not reach the Login dialog.

  • Template: app
  • Created ABP Studio Version: 0.8.1
  • Tiered: No
  • UI Framework: maui-blazor
  • Theme: leptonx
  • Theme Style: system
  • Database Provider: ef
  • Database Management System: sqlserver
  • Separate Tenant Schema: No
  • Mobile Framework: maui
  • Public Website: No
  • Optional Modules:
    • GDPR
    • TextTemplateManagement
    • LanguageManagement
    • AuditLogging
    • SaaS
    • OpenIddictAdmin

The results are the same whether Android or a Windows Machine is targeted.

  1. Generate the solution (project name Handheld)
  2. In Visual Studio: Set the Handheld.MauiBlazor project to the default Windows Machine or a suitable Android device For Android we'd also startup an Android Adb Command Prompt and execute the appropriate command e.g. adb reverse tcp:44330 tcp:44330
  3. Start the backend Handheld.HttpApi.Host project (we usually change it from IIS Express to the "Command prompt" so we can see its output
  4. Deploy / Start the Maui Blazor Hybrid app Handheld.MauiBlazor
  5. Click Login Grey overlay appears, but the Login dialog is never presented!
  6. A call is made to the server and which shows up in the logs.txt of the Handheld.HttpApi.Host project (see logs.txt file Logs.txt)

We also notice that the images on the Home page cannot be resolved, and when a "Windows Machine" is the target which allows us to use F12 Developer Tools and view the console, we can see this

In case its relevant the OpenIddictApplications table contains the following

We're aware of the ABP article on ngrok https://abp.io/community/articles/tunnel-your-local-host-address-to-a-public-url-with-ngrok-4cywnocj and the MS MAUI Team's instructions on using Visual Studio Dev Tunnels (which seems a lot easier) MS MAUI Dev Tunnels with regard to localhost and mobile device development.

So if the ABP Studio generated projects do need subsequent configuration re exposing local host to be able to Login, can you let us know which AppSettings.json files(s) we need to alter, prior to running the **DbMigrator ** project to re-seed the database, in order for us to be able to Login via the Maui Blazor Hybrid App?

Or if this issue is nothing to do with localhost, please advise us on this Login page issue?


5 Answer(s)
  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    I will check it

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Hi,

    I can reproduce the problem, we will fix it in the next patch version.

    your ticket was refunded.

    Here is the temporary solution

     public sealed class MyWebAuthenticator
     {
         /// <summary>
         /// Begin an authentication flow by navigating to the specified url and waiting for a callback/redirect to the callbackUrl scheme.
         /// </summary>
         /// <param name="authorizeUri">Url to navigate to, beginning the authentication flow.</param>
         /// <param name="callbackUri">Expected callback url that the navigation flow will eventually redirect to.</param>
         /// <returns>Returns a result parsed out from the callback url.</returns>
         public static Task<WebAuthenticatorResult> AuthenticateAsync(Uri authorizeUri, Uri callbackUri) =>
             Instance.Authenticate(authorizeUri, callbackUri);
    
         private static readonly MyWebAuthenticator Instance = new MyWebAuthenticator();
    
         private Dictionary<string, TaskCompletionSource<Uri>> tasks = new Dictionary<string, TaskCompletionSource<Uri>>();
    
         private MyWebAuthenticator()
         {
             Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Activated += CurrentAppInstance_Activated;
         }
    
         [System.Runtime.CompilerServices.ModuleInitializer]
         internal static void Init()
         {
             try
             {
                 OnAppCreation();
             }
             catch (Exception ex)
             {
                 System.Diagnostics.Trace.WriteLine("WinUIEx: Failed to initialize the WebAuthenticator: " + ex.Message,
                     "WinUIEx");
             }
         }
    
         private static bool IsUriProtocolDeclared(string scheme)
         {
             if (global::Windows.ApplicationModel.Package.Current is null)
                 return false;
             var docPath = Path.Combine(global::Windows.ApplicationModel.Package.Current.InstalledLocation.Path,
                 "AppxManifest.xml");
             var doc = XDocument.Load(docPath, LoadOptions.None);
             var reader = doc.CreateReader();
             var namespaceManager = new XmlNamespaceManager(reader.NameTable);
             namespaceManager.AddNamespace("x", "http://schemas.microsoft.com/appx/manifest/foundation/windows10");
             namespaceManager.AddNamespace("uap", "http://schemas.microsoft.com/appx/manifest/uap/windows10");
    
             // Check if the protocol was declared
             var decl = doc.Root?.XPathSelectElements(
                 $"//uap:Extension[@Category='windows.protocol']/uap:Protocol[@Name='{scheme}']", namespaceManager);
    
             return decl != null && decl.Any();
         }
    
         private static System.Collections.Specialized.NameValueCollection? GetState(
             Microsoft.Windows.AppLifecycle.AppActivationArguments activatedEventArgs)
         {
             if (activatedEventArgs.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol &&
                 activatedEventArgs.Data is IProtocolActivatedEventArgs protocolArgs)
             {
                 return GetState(protocolArgs);
             }
    
             return null;
         }
    
         private static NameValueCollection? GetState(IProtocolActivatedEventArgs protocolArgs)
         {
             NameValueCollection? vals = null;
             try
             {
                 vals = System.Web.HttpUtility.ParseQueryString(protocolArgs.Uri.Query);
             }
             catch { }
    
             try
             {
                 if (vals is null || !(vals["state"] is string))
                 {
                     var fragment = protocolArgs.Uri.Fragment;
                     if (fragment.StartsWith("#"))
                     {
                         fragment = fragment.Substring(1);
                     }
    
                     vals = System.Web.HttpUtility.ParseQueryString(fragment);
                 }
             }
             catch { }
    
             if (vals != null && vals["state"] is string state)
             {
                 var vals2 = System.Web.HttpUtility.ParseQueryString(state);
                 // Some services doesn't like & encoded state parameters, and breaks them out separately.
                 // In that case copy over the important values
                 if (vals.AllKeys.Contains("appInstanceId") && !vals2.AllKeys.Contains("appInstanceId"))
                     vals2.Add("appInstanceId", vals["appInstanceId"]);
                 if (vals.AllKeys.Contains("signinId") && !vals2.AllKeys.Contains("signinId"))
                     vals2.Add("signinId", vals["signinId"]);
                 return vals2;
             }
    
             return null;
         }
    
         private static void OnAppCreation()
         {
             var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent()?.GetActivatedEventArgs();
             if (activatedEventArgs is null)
                 return;
             var state = GetState(activatedEventArgs);
             if (state is not null && state["appInstanceId"] is string id && state["signinId"] is string signinId &&
                 !string.IsNullOrEmpty(signinId))
             {
                 var instance = Microsoft.Windows.AppLifecycle.AppInstance.GetInstances().Where(i => i.Key == id)
                     .FirstOrDefault();
    
                 if (instance is not null && !instance.IsCurrent)
                 {
                     // Redirect to correct instance and close this one
                     instance.RedirectActivationToAsync(activatedEventArgs).AsTask().Wait();
                     System.Diagnostics.Process.GetCurrentProcess().Kill();
                 }
             }
             else
             {
                 var thisInstance = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent();
                 if (string.IsNullOrEmpty(thisInstance.Key))
                 {
                     Microsoft.Windows.AppLifecycle.AppInstance.FindOrRegisterForKey(Guid.NewGuid().ToString());
                 }
             }
         }
    
         private void CurrentAppInstance_Activated(object? sender, Microsoft.Windows.AppLifecycle.AppActivationArguments e)
         {
             if (e.Kind == Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol)
             {
                 if (e.Data is IProtocolActivatedEventArgs protocolArgs)
                 {
                     var vals = GetState(protocolArgs);
                     if (vals is not null && vals["signinId"] is string signinId)
                     {
                         ResumeSignin(protocolArgs.Uri, signinId);
                     }
                 }
             }
         }
    
         private void ResumeSignin(Uri callbackUri, string signinId)
         {
             if (signinId != null && tasks.ContainsKey(signinId))
             {
                 var task = tasks[signinId];
                 tasks.Remove(signinId);
                 task.TrySetResult(callbackUri);
             }
         }
    
         private async Task<WebAuthenticatorResult> Authenticate(Uri authorizeUri, Uri callbackUri)
         {
             if (global::Windows.ApplicationModel.Package.Current is null)
             {
                 throw new InvalidOperationException("The WebAuthenticator requires a packaged app with an AppxManifest");
             }
    
             var scheme = callbackUri.Scheme;
             if(scheme == "https")
             {
                 scheme = callbackUri.AbsolutePath.Substring(callbackUri.AbsolutePath.LastIndexOf('/') + 1);
             }
    
             if (!IsUriProtocolDeclared(scheme))
             {
                 throw new InvalidOperationException(
                     $"The URI Scheme {callbackUri.Scheme} is not declared in AppxManifest.xml");
             }
    
             var g = Guid.NewGuid();
             UriBuilder b = new UriBuilder(authorizeUri);
    
             var query = System.Web.HttpUtility.ParseQueryString(authorizeUri.Query);
             var state = $"appInstanceId={Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().Key}&signinId={g}";
             if (query["state"] is string oldstate && !string.IsNullOrEmpty(oldstate))
             {
                 // Encode the state parameter
                 state += "&state=" + System.Web.HttpUtility.UrlEncode(oldstate);
             }
    
             query["state"] = state;
             b.Query = query.ToString();
             authorizeUri = b.Uri;
    
             var tcs = new TaskCompletionSource<Uri>();
             var process = new System.Diagnostics.Process();
             process.StartInfo.FileName = "rundll32.exe";
             process.StartInfo.Arguments = "url.dll,FileProtocolHandler " + authorizeUri.ToString();
             process.StartInfo.UseShellExecute = true;
             process.Start();
             tasks.Add(g.ToString(), tcs);
             var uri = await tcs.Task.ConfigureAwait(false);
             return new WebAuthenticatorResult(uri);
         }
     }
    
    
    public class MyMauiAuthenticationBrowser : IBrowser, ITransientDependency
    {
        public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
        {
            try
            {
                WebAuthenticatorResult result = null;
                var webAuthenticatorOptions = new WebAuthenticatorOptions
                {
                    Url = new Uri(options.StartUrl),
                    CallbackUrl = new Uri(options.EndUrl),
                    PrefersEphemeralWebBrowserSession = true
                };
    
    #if WINDOWS
            result =
    await Platforms.Windows.MyWebAuthenticator.AuthenticateAsync(webAuthenticatorOptions.Url, webAuthenticatorOptions.CallbackUrl);
    #else
                result = await WebAuthenticator.AuthenticateAsync(new WebAuthenticatorOptions
                {
                    Url = new Uri(options.StartUrl),
                    CallbackUrl = new Uri(options.EndUrl),
                    PrefersEphemeralWebBrowserSession = true
                });
    #endif
    
    
                return new BrowserResult
                {
                    Response = ToRawIdentityUrl(options.EndUrl, result)
                };
            }
            catch (TaskCanceledException)
            {
                return new BrowserResult
                {
                    ResultType = BrowserResultType.UserCancel
                };
            }
            catch (Exception ex)
            {
                return new BrowserResult
                {
                    ResultType = BrowserResultType.UnknownError,
                    Error = ex.ToString()
                };
            }
        }
    
        private static string ToRawIdentityUrl(string redirectUrl, WebAuthenticatorResult result)
        {
            if (DeviceInfo.Platform == DevicePlatform.WinUI)
            {
                var parameters = result.Properties.Select(pair => $"{pair.Key}={pair.Value}");
                var modifiedParameters = parameters.ToList();
    
                var stateParameter = modifiedParameters
                    .FirstOrDefault(p => p.StartsWith("state", StringComparison.OrdinalIgnoreCase));
    
                if (!string.IsNullOrWhiteSpace(stateParameter))
                {
                    // Remove the state key added by WebAuthenticator that includes appInstanceId
                    modifiedParameters = modifiedParameters
                        .Where(p => !p.StartsWith("state", StringComparison.OrdinalIgnoreCase)).ToList();
    
                    stateParameter = System.Web.HttpUtility.UrlDecode(stateParameter).Split('&').Last();
                    modifiedParameters.Add(stateParameter);
                }
    
                var values = string.Join("&", modifiedParameters);
                return $"{redirectUrl}#{values}";
            }
            else
            {
                var parameters = result.Properties.Select(pair => $"{pair.Key}={pair.Value}");
                var values = string.Join("&", parameters);
    
                return $"{redirectUrl}#{values}";
            }
        }
    }
    
    switch (configuration["OAuthConfig:GrantType"])
    {
        case "password":
            context.Services.AddSingleton<IExternalAuthService, PasswordFlowExternalAuthService>();
            var authServiceClientBuild = context.Services.AddHttpClient(PasswordFlowExternalAuthService.HttpClientName);
    #if DEBUG
            authServiceClientBuild.ConfigurePrimaryHttpMessageHandler(GetInsecureHandler);
    #endif
            break;
        case "code":
            context.Services.AddSingleton<IExternalAuthService, CodeFlowExternalAuthService>();
            Configure<OidcClientOptions>(configuration.GetSection("OAuthConfig"));
    
            context.Services.AddTransient<OidcClient>(sp =>
            {
                var options = sp.GetRequiredService<IOptions<OidcClientOptions>>().Value;
                options.Browser = sp.GetRequiredService<MyMauiAuthenticationBrowser>(); // change to MyMauiAuthenticationBrowser
    
    #if DEBUG
                options.BackchannelHandler = GetInsecureHandler();
    #endif
    
                return new OidcClient(options);
            });
            break;
    }
    
  • User Avatar
    0
    DWaterfield created

    Hi,

    Thanks for the temporary solution. Can we just check we have these changes in the correct place as we're getting a compilation error.

    We have the first two code snippets in here next to the classes they're replacing as this seemed to be the correct location?:

    and the 3rd code change is a straight single line change

    This gives us the expected "type or namespace name..... error as MyMauiAuthenticationBrowser is in the Handheld.Maui project and we're making this single line change in the HandheldMauiBlazorModule.cs file of the Handheld.MauiBlazor project

    Is it the intention that we add a project reference from the Handheld.MauiBlazor to the Handheld.Maui project?

    The Visual Studio hint/help is of course just that

    However even when we do that, we have new errors which is why we think adding a reference between the two projects was not the intention?

    Can you please advise us where we've gone wrong adding the two classes MyWebAuthenticator and MyMauiAuthenticationBrowser and how we get this temporary solution into the solution as intended?

    Thanks.

  • User Avatar
    0
    liangshiwei created
    Support Team Fullstack Developer

    Please put them to MauiBlazor project

  • User Avatar
    0
    DWaterfield created

    Thank you that works. We look forward to these changes coming through in the next patch version

Made with ❤️ on ABP v9.1.0-preview. Updated on November 11, 2024, 11:11