WebAuthenticator is spawning a new window in MAUI after authentication

#269 · closed · 23 comments

View on GitHub ↗

bwinklesky

Hi- I'm not sure if this is a bug or if my configuration is wrong, but I've gone through the setup described in the Using with MAUI page, but my applications are spawning a new window and I'm not able to intercept the access token. I'm using .NET 10.

Comments

dotMorten

This would happen if you miss the startup code or if your oauth service doesn’t preserve the State parameter per the oauth spec. See https://dotmorten.github.io/WinUIEx/concepts/Maui.html#use-winuiexs-webauthenticator-instead-of-net-mauis

bwinklesky

I followed those steps and still the same result. <img width="1117" height="610" alt="Image" src="https://github.com/user-attachments/assets/74276960-b76b-4e65-a3b1-50e729875109" /> <img width="1341" height="523" alt="Image" src="https://github.com/user-attachments/assets/465797c1-14c8-4b66-a936-75198718a245" /> <img width="1210" height="672" alt="Image" src="https://github.com/user-attachments/assets/5c4edddb-9750-42f6-825f-a6bbf7680431" /> ``` using Microsoft.Extensions.Logging; using CommunityToolkit.Maui; using Esri.ArcGISRuntime.Maui; using Esri.ArcGISRuntime.Toolkit.Maui; using Klyk.Mobile; using Microsoft.Maui.LifecycleEvents; #if WINDOWS using WinUIEx; #endif namespace PrecisionFarms { public static class MauiProgram { public static MauiApp CreateMauiApp() { var builder = MauiApp.CreateBuilder(); builder.UseMauiApp<App>().ConfigureFonts(fonts => { fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }) .UseMauiCommunityToolkit() .UseArcGISRuntime() .UseArcGISToolkit().ConfigureApp(); #if WINDOWS builder.ConfigureLifecycleEvents(events => { events.AddWindows(wndLifeCycleBuilder => { wndLifeCycleBuilder.OnWindowCreated(window => { window.CenterOnScreen(1024,768); var manager = WinUIEx.WindowManager.Get(window); manager.PersistenceId = "MainWindowPersistanceId"; // Remember window position and size across runs manager.MinWidth = 640; manager.MinHeight = 480; }); }); }); #endif #if DEBUG builder.Logging.AddDebug(); #endif return builder.Build(); } } } ```

dotMorten

Then it’s most likely your oauth service not preserving or mangling the state parameter.

bwinklesky

I'm using asp.net core authentication provider and don't see any options about preserving state. Do you have any ideas on how to check to see if it's mangling the state parameter? ``` var google = authenticationSection.GetSection("Google"); if (google.Exists()) { authentication.AddGoogle(options => { options.ClientId = google["ClientId"]; options.ClientSecret = google["ClientSecret"]; options.SaveTokens = true; //options.Events.OnTicketReceived += TokenUtils.OnTicketRecieved; }); } ```

dotMorten

I wouldn't know anything about that, but you could hook it up with the source code here and inspect what is happening here: https://github.com/dotMorten/WinUIEx/blob/123947d3be7cecf0a9716b9e5f6648818d682cc7/src/WinUIEx/WebAuthenticator.cs#L178

dotMorten

I deleted your comment on the oauth state url - I believe you don't want to post the raw value of that here, since it contains your credential tokens.

bwinklesky

i'll try adding the source and see if i can inspect it.

bwinklesky

I just tried using the source, and it's not hitting line 178 when it opens the new window.

dotMorten

Try debugging from the CheckOAuthRedirectionActivation() call

bwinklesky

On first load it hits line 183 on the WebAuthenticator class to return false. Once you try logging in, the CheckOAuthRedirectionActivation() in the "App" never gets called.

dotMorten

It wouldn’t call that until the redirect from the browser happens

bwinklesky

Not sure what you mean. It seems like adding this should intercept the redirect from the browser, but maybe I'm using this in the wrong place? The browser does open the appropriate app, but not the active one. ``` public App() { if (WinUIEx.WebAuthenticator.CheckOAuthRedirectionActivation()) { return; } this.InitializeComponent(); } ```

dotMorten

Yes it’ll open a new instance and hit this code and should then forward the info to the existing instance and shut this instance down. If you see a new instance launching it would have to be hitting this app code

bwinklesky

The debugger isn't picking up the new instance. And the browser doesn't close after the redirect.

dotMorten

Check the above linked issue. It could be related to having multiple instances running at the same time. If not the issue with your debugging is likely that Visual Studio isn't set up to also debug newly launched instances. You could also add a call to `System.Diagnostics.Debugger.Launch()` instead of setting a breakpoint to force the load. Closing for now but feel free to re-open if you find out more details.

bwinklesky

Thanks for the help. I don't think it's visual studio, because the sample app works. The appInstanceId is populated in the url, and I don't think that has anything to do with it. I'm at a loss at this point and not sure what to do to get it working.

bwinklesky

@dotMorten So, I did a bit of a dissection of the library to figure out what was going on. Turns out the solution was a pretty simple. The default .NET MAUI WebAuthenticator doesn't pass any "state", and I was thinking the "state" you mentioned was the authentication state that ASP.NET Core uses. What I needed to do on the server side was to extract the state WinUIEx sends which has the appInstanceId and the signinId and return that back to the client in the query string variables. Not sure if you feel that deserves some documentation or not. I'm using a pretty standard implementation of AspNet.Security.OAuth.ArcGIS and it could help future users integrate with Windows.

dotMorten

Round tripping the state parameter is part of the oauth spec. If your service doesn’t do this, it’s not really an oauth service 😉

bwinklesky

That's fair. You would think Microsoft would provide that in their sample if that was going create a problem using Windows. The service works fine with iOS, Android and MacCatalyst.

dotMorten

Those platforms are all have single-instance apps and doesn’t need it to resume the correct instance. You could also configure the windows app to be single instance

bwinklesky

I realize they are single instance apps, but if you're a person who isn't an expert in all things oauth/windows you're not going to know that you need to round-trip the state parameter for it to work properly. If you prompt Copilot to write a controller for mobile authentication it doesn't round-trip the state. Gemini returns a similar implementation. If there was a small blurb in the README that demonstrated how this WebAuthenticator needs the state to function properly it would have saved me a lot of time. If it was there then I assume AI would have given me a heads up when it recommended using this library. ``` [Route("mobileauth")] public class MobileAuthController : Controller { private readonly SignInManager<ApplicationUser> _signInManager; private readonly UserManager<ApplicationUser> _userManager; private readonly ITokenService _tokenService; // your JWT generator public MobileAuthController( SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, ITokenService tokenService) { _signInManager = signInManager; _userManager = userManager; _tokenService = tokenService; } // --------------------------------------------------------- // 1. Start external login (Google, Apple, etc.) // --------------------------------------------------------- [HttpGet("{provider}")] public IActionResult Start(string provider, string callback, int appId) { var redirectUrl = Url.Action(nameof(ExternalCallback), "MobileAuth", new { callback, appId }); var props = _signInManager.ConfigureExternalAuthenticationProperties( provider, redirectUrl); return Challenge(props, provider); } // --------------------------------------------------------- // 2. Provider returns here (Google → /signin-google → this) // --------------------------------------------------------- [HttpGet("callback")] public async Task<IActionResult> ExternalCallback(string callback, int appId) { // Read external login info var info = await _signInManager.GetExternalLoginInfoAsync(); if (info == null) return BadRequest("External login failed"); // Sign in or create user var result = await _signInManager.ExternalLoginSignInAsync( info.LoginProvider, info.ProviderKey, isPersistent: false); ApplicationUser user; if (!result.Succeeded) { // Create user if needed user = new ApplicationUser { Email = info.Principal.FindFirstValue(ClaimTypes.Email), UserName = info.Principal.FindFirstValue(ClaimTypes.Email) }; await _userManager.CreateAsync(user); await _userManager.AddLoginAsync(user, info); } else { user = await _userManager.FindByLoginAsync( info.LoginProvider, info.ProviderKey); } // --------------------------------------------------------- // 3. Issue your own JWT // --------------------------------------------------------- var jwt = _tokenService.CreateToken(user); // --------------------------------------------------------- // 4. Redirect back to MAUI app // --------------------------------------------------------- var uri = $"{callback}://auth?token={jwt}&appId={appId}"; return Redirect(uri); } } ```

dotMorten

I get that, but I'm going to keep it focus on documenting the client-side of things. I'd rather not get into also documenting how to build an oauth-service that follows the standard. The state parameter isn't only for resuming a multi-instance app. It can be used for a lot of things. You could consider opening an issue in AspNet.Security.OAuth.ArcGIS to ensure they handle the state parameter and document it correctly.

bwinklesky

I don't think it needs to be a full document, but just a note that says this library relies on the state parameter to open the appropriate app instance since that's how it differs in scope from the built in WebAuthenticator, which would be considered the client-side of things.