Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ namespace FwLiteMaui;

public static class FwLiteMauiKernel
{
/// <summary>
/// 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).
/// </summary>
public static readonly IReadOnlyDictionary<string, string?> DefaultConfiguration = new Dictionary<string, string?>
{
//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)
Expand Down Expand Up @@ -135,8 +146,9 @@ public static void AddFwLiteMauiServices(this IServiceCollection services,
services.AddOptions<FwLiteMauiConfig>().BindConfiguration("FwLiteMaui");
var fwLiteMauiConfig = configuration.GetSection("FwLiteMaui").Get<FwLiteMauiConfig>() ?? 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<LcmCrdtConfig>(config =>
{
Expand All @@ -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<HarmonyConfig>(config =>
{
Expand Down
1 change: 1 addition & 0 deletions backend/FwLite/FwLiteMaui/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<App>()
Expand Down
6 changes: 6 additions & 0 deletions backend/FwLite/FwLiteShared/Auth/AuthConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public class AuthConfig
public Func<object?>? GetParentActivityOrWindow { get; set; }
public Action? AfterLoginWebView { get; set; }
/// <summary>
/// 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 <see cref="CustomWebUiFactory"/> path ignore it.
/// </summary>
public Func<Uri, Task>? OpenSystemBrowser { get; set; }
/// <summary>
/// When set, interactive login uses this MSAL <c>ICustomWebUi</c> instead of MSAL's own system-browser flow.
/// Mac Catalyst uses it to run an <c>ASWebAuthenticationSession</c> (shares Safari's session, no localhost listener),
/// because the MSAL package has no Mac Catalyst build and falls back to its desktop implementation there.
Expand Down
14 changes: 12 additions & 2 deletions backend/FwLite/FwLiteShared/Auth/AuthService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.Json.Serialization;
using FwLiteShared.Projects;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -41,18 +42,27 @@ public async Task<ServerStatus[]> Servers()
// Not [JSInvokable]: a CancellationToken can't be marshaled from JS.
public async Task<LoginResult> 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;
}
}
Expand Down
45 changes: 41 additions & 4 deletions backend/FwLite/FwLiteShared/Auth/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public async Task<SignInResult> 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);
}
Expand All @@ -46,7 +46,11 @@ public async Task<SignInResult> 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());
Expand All @@ -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,
Expand Down
Loading