diff --git a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs index f2568bef9c..5a8edae35b 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs +++ b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs @@ -19,6 +19,17 @@ namespace FwLiteMaui; public static class FwLiteMauiKernel { + /// + /// Lowest-precedence configuration, registered before environment variables in MauiProgram so each entry + /// can be overridden at runtime (e.g. Logging__LogLevel__FwLiteShared.Auth.LoggerAdapter=Information). + /// + public static readonly IReadOnlyDictionary DefaultConfiguration = new Dictionary + { + //MSAL is chatty at Information; Warning still surfaces listener and token failures + ["Logging:LogLevel:FwLiteShared.Auth.LoggerAdapter"] = "Warning", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database"] = "Warning", + }; + public static void AddFwLiteMauiServices(this IServiceCollection services, ConfigurationManager configuration, ILoggingBuilder logging) @@ -135,8 +146,9 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, services.AddOptions().BindConfiguration("FwLiteMaui"); var fwLiteMauiConfig = configuration.GetSection("FwLiteMaui").Get() ?? new(); var baseDataPath = fwLiteMauiConfig.BaseDataDir; - logging.AddFilter("FwLiteShared.Auth.LoggerAdapter", LogLevel.Warning); - logging.AddFilter("Microsoft.EntityFrameworkCore.Database", LogLevel.Warning); + //filters come from configuration (defaults in DefaultConfiguration) so a user can raise a category at + //runtime, e.g. Logging__LogLevel__FwLiteShared.Auth.LoggerAdapter=Information for MSAL sign in details + logging.AddConfiguration(configuration.GetSection("Logging")); Directory.CreateDirectory(baseDataPath); services.Configure(config => { @@ -146,6 +158,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, { config.CacheFileName = fwLiteMauiConfig.AuthCacheFilePath; config.SystemWebViewLogin = true; + config.OpenSystemBrowser = uri => Launcher.Default.OpenAsync(uri); }); services.Configure(config => { diff --git a/backend/FwLite/FwLiteMaui/MauiProgram.cs b/backend/FwLite/FwLiteMaui/MauiProgram.cs index faba947f20..3978644312 100644 --- a/backend/FwLite/FwLiteMaui/MauiProgram.cs +++ b/backend/FwLite/FwLiteMaui/MauiProgram.cs @@ -46,6 +46,7 @@ public static MauiApp CreateMauiApp() var builder = MauiApp.CreateBuilder(); // MAUI doesn't load environment variables into configuration by default (unlike ASP.NET Core), // so add them here. This lets config sections like "FwLiteMaui" be set via e.g. FwLiteMaui__BaseDataDir. + builder.Configuration.AddInMemoryCollection(FwLiteMauiKernel.DefaultConfiguration); builder.Configuration.AddEnvironmentVariables(); builder .UseMauiApp() diff --git a/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs index 0db7dea81e..7c5f0b79fc 100644 --- a/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs +++ b/backend/FwLite/FwLiteShared/Auth/AuthConfig.cs @@ -22,6 +22,12 @@ public class AuthConfig public Func? GetParentActivityOrWindow { get; set; } public Action? AfterLoginWebView { get; set; } /// + /// Opens the system browser for MSAL's desktop sign-in flow. When set, MSAL calls this instead of its own + /// Process.Start, which lets us log the loopback redirect URI (port) the browser must come back to. + /// Only MSAL's DefaultOsBrowserWebUi (Windows) uses it; Android and the path ignore it. + /// + public Func? OpenSystemBrowser { get; set; } + /// /// When set, interactive login uses this MSAL ICustomWebUi instead of MSAL's own system-browser flow. /// Mac Catalyst uses it to run an ASWebAuthenticationSession (shares Safari's session, no localhost listener), /// because the MSAL package has no Mac Catalyst build and falls back to its desktop implementation there. diff --git a/backend/FwLite/FwLiteShared/Auth/AuthService.cs b/backend/FwLite/FwLiteShared/Auth/AuthService.cs index b72ce2ab6d..8bb04090ba 100644 --- a/backend/FwLite/FwLiteShared/Auth/AuthService.cs +++ b/backend/FwLite/FwLiteShared/Auth/AuthService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json.Serialization; using FwLiteShared.Projects; using Microsoft.Extensions.Logging; @@ -41,18 +42,27 @@ public async Task Servers() // Not [JSInvokable]: a CancellationToken can't be marshaled from JS. public async Task SignInWebView(LexboxServer server, CancellationToken cancellation) { + var started = Stopwatch.GetTimestamp(); try { var result = await clientFactory.GetClient(server).SignIn(string.Empty, cancellation);//returnUrl does nothing here if (!result.HandledBySystemWebView) throw new InvalidOperationException("Sign in not handled by system web view"); options.Value.AfterLoginWebView?.Invoke(); + logger.LogInformation("Web view sign in to {Server} succeeded after {Elapsed}", + server.Authority, Stopwatch.GetElapsedTime(started)); return LoginResult.Success; } catch (Exception e) { var classified = OAuthClient.ClassifyInteractiveLoginFailure(e); - if (classified is null) throw; - logger.LogInformation(e, "Web view sign in did not complete: {LoginResult}", classified); + if (classified is null) + { + logger.LogError(e, "Web view sign in to {Server} failed after {Elapsed}", + server.Authority, Stopwatch.GetElapsedTime(started)); + throw; + } + logger.LogInformation(e, "Web view sign in to {Server} did not complete after {Elapsed}: {LoginResult}", + server.Authority, Stopwatch.GetElapsedTime(started), classified); return classified.Value; } } diff --git a/backend/FwLite/FwLiteShared/Auth/OAuthService.cs b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs index d752a41569..69d8994ec7 100644 --- a/backend/FwLite/FwLiteShared/Auth/OAuthService.cs +++ b/backend/FwLite/FwLiteShared/Auth/OAuthService.cs @@ -26,7 +26,7 @@ public async Task SubmitLoginRequest(IPublicClientApplication appl { if (options.Value.SystemWebViewLogin) { - await HandleSystemWebViewLogin(application, cancellation); + await HandleSystemWebViewLogin(application, lexboxServer, cancellation); globalEventBus.PublishEvent(new AuthenticationChangedEvent(lexboxServer, AuthenticationChangeCause.Login)); return new(null, true); } @@ -46,7 +46,11 @@ public async Task SubmitLoginRequest(IPublicClientApplication appl return new(uri, false); } - private async Task HandleSystemWebViewLogin(IPublicClientApplication application, CancellationToken cancellation) + private int _pendingSystemWebViewLogins; + + private async Task HandleSystemWebViewLogin(IPublicClientApplication application, + LexboxServer lexboxServer, + CancellationToken cancellation) { var request = application.AcquireTokenInteractive(OAuthClient.DefaultScopes) .WithParentActivityOrWindow(options.Value.GetParentActivityOrWindow?.Invoke()); @@ -56,9 +60,42 @@ private async Task HandleSystemWebViewLogin(IPublicClientApplication application } else { - request = request.WithUseEmbeddedWebView(false).WithSystemWebViewOptions(new() { }); + var webViewOptions = new SystemWebViewOptions(); + if (options.Value.OpenSystemBrowser is { } openSystemBrowser) + { + webViewOptions.OpenBrowserAsync = authorizationUri => + { + //the loopback listener only exists inside this process and only until the first request, so + //this is the one place we learn which port the browser has to come back to + logger.LogInformation("Opening system browser to sign in to {Server}; waiting for redirect to {RedirectUri}", + lexboxServer.Authority, + HttpUtility.ParseQueryString(authorizationUri.Query).Get("redirect_uri")); + return openSystemBrowser(authorizationUri); + }; + } + request = request.WithUseEmbeddedWebView(false).WithSystemWebViewOptions(webViewOptions); + } + + logger.LogInformation("System web view sign in to {Server} started", lexboxServer.Authority); + Interlocked.Increment(ref _pendingSystemWebViewLogins); + try + { + await request.ExecuteAsync(cancellation); + } + finally + { + Interlocked.Decrement(ref _pendingSystemWebViewLogins); } - await request.ExecuteAsync(cancellation); + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + //a sign in still waiting for the browser dies with the process: the browser then lands on a loopback + //port nobody listens on ("localhost refused to connect"). Make that visible in the log. + var pending = Volatile.Read(ref _pendingSystemWebViewLogins); + if (pending > 0) + logger.LogWarning("App is stopping with {Count} sign in(s) still waiting for the browser to return", pending); + return base.StopAsync(cancellationToken); } public async Task<(AuthenticationResult, string ClientReturnUrl)> FinishLoginRequest(Uri uri,