From a479893b9ce9859a8be1200f8fe2b7a8e09089ba Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 14 Aug 2026 03:36:19 -0700 Subject: [PATCH 1/9] Port over LoopbackHttpListener --- .../Authentication/BrowserResponse.cs | 39 +++ .../Authentication/LoopbackHttpListener.cs | 247 ++++++++++++++++++ .../SystemBrowserWebAuthenticationBroker.cs | 129 +++++++++ 3 files changed, 415 insertions(+) create mode 100644 src/Avalonia.Controls.WebView.Core/Authentication/BrowserResponse.cs create mode 100644 src/Avalonia.Controls.WebView.Core/Authentication/LoopbackHttpListener.cs create mode 100644 src/Avalonia.Controls.WebView.Core/Authentication/SystemBrowserWebAuthenticationBroker.cs diff --git a/src/Avalonia.Controls.WebView.Core/Authentication/BrowserResponse.cs b/src/Avalonia.Controls.WebView.Core/Authentication/BrowserResponse.cs new file mode 100644 index 0000000..111fd2f --- /dev/null +++ b/src/Avalonia.Controls.WebView.Core/Authentication/BrowserResponse.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Net; + +namespace Avalonia.Controls.Authentication; + +/// +/// Represents the HTTP response sent to the browser after the authentication callback is received. +/// +public sealed class BrowserResponse +{ + private Uri? _redirect; + internal BrowserResponse(Stream outputStream) + { + StatusCode = HttpStatusCode.OK; + OutputStream = outputStream; + } + + /// + /// Gets or sets the HTTP status code of the response. + /// + public HttpStatusCode StatusCode { get; set; } + + /// + /// Gets the stream to which the response content can be written. + /// + public Stream OutputStream { get; } + + /// + /// Configures the response to redirect the browser to the specified URI. + /// + public void Redirect(Uri uri) + { + _redirect = uri; + StatusCode = HttpStatusCode.Found; + } + + internal Uri? ReadRedirect() => _redirect; +} diff --git a/src/Avalonia.Controls.WebView.Core/Authentication/LoopbackHttpListener.cs b/src/Avalonia.Controls.WebView.Core/Authentication/LoopbackHttpListener.cs new file mode 100644 index 0000000..4911439 --- /dev/null +++ b/src/Avalonia.Controls.WebView.Core/Authentication/LoopbackHttpListener.cs @@ -0,0 +1,247 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Avalonia.Controls.Authentication; + +/// +/// Local HTTP listener that captures an OAuth redirect callback from the browser. +/// +/// +/// Uses bound to the loopback address rather than . +/// on macOS does not reliably accept connections when its prefix contains a +/// literal IP address such as http://127.0.0.1:port/, causing the browser redirect to fail with a +/// "Can't Connect to the Server" error. binds directly to the desired +/// and avoids this platform specific limitation. +/// +[UnsupportedOSPlatform("browser")] +internal sealed class LoopbackHttpListener : IDisposable +{ + private const int HttpRequestBufferSize = 4096; + + private readonly TcpListener _listener; + private readonly string _redirectPath; + + /// + /// Starts a listener on the loopback interface. + /// + /// Port to listen on, or 0 to let the OS allocate a free one. + /// Relative redirect path (e.g. /callback) that completes the flow. + public LoopbackHttpListener(int port, string redirectPath) + { + _listener = new TcpListener(IPAddress.Loopback, port) { ExclusiveAddressUse = true }; + _redirectPath = redirectPath; + + try + { + _listener.Start(); + } + catch (Exception ex) + { + var actualPort = ((IPEndPoint)_listener.LocalEndpoint).Port; + throw new InvalidOperationException( + $"Failed to start the local callback listener on 127.0.0.1:{actualPort}.", ex); + } + } + + /// + /// Port the listener is actually bound to. + /// + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public async Task WaitForCallbackAsync( + Func? responseFactory, + CancellationToken cancellationToken) + { + // Keep looping until the HTTP client sends a matching request. + // Browsers can send OPTIONS or favicon.ico first, before the expected callback. + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var client = await _listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); + + try + { + var requestUri = await ReadRequestUriAsync(client, cancellationToken).ConfigureAwait(false); + if (requestUri is null) + continue; + + var found = requestUri.AbsolutePath == _redirectPath; + + BrowserResponse? response; + if (found && responseFactory is not null) + { + var stream = new MemoryStream(); + response = new BrowserResponse(stream); + await responseFactory(requestUri, response).ConfigureAwait(false); + } + else + { + response = found ? BuildDefaultResponse(HttpStatusCode.OK) : BuildDefaultResponse(HttpStatusCode.NotFound); + } + + await SendResponseAsync(client, response, cancellationToken).ConfigureAwait(false); + if (found) + { + return requestUri; + } + } + catch (IOException) + { + // Client disconnected or sent a malformed request; wait for the next one. + } + catch (SocketException) + { + // Client disconnected; wait for the next one. + } + } + } + + public void Dispose() + { + try + { + _listener.Dispose(); + } + catch + { + // Ignore errors during cleanup. + } + } + + private static async Task ReadRequestUriAsync(TcpClient client, CancellationToken cancellationToken) + { + var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, + detectEncodingFromByteOrderMarks: false, bufferSize: HttpRequestBufferSize, leaveOpen: true); + + // Parse the HTTP request line: "GET /callback?code=...&state=... HTTP/1.1" + var requestLine = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (requestLine is null) + return null; + + // Drain all remaining headers so the browser can receive our response. + // An empty line signals the end of the HTTP header section. + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { Length: > 0 }) + { + } + + var parts = requestLine.Split(' '); + if (parts.Length < 2) + return null; + + var rawTarget = parts[1]; + // Only origin-form targets (starting with '/') are valid for callback GETs. + // Absolute-form, authority-form and asterisk-form targets are rejected so they can't be + // concatenated into a spoofed URI. + if (rawTarget.Length == 0 || rawTarget[0] != '/') + return null; + + // The authority comes from the accepted socket, never from the request's Host header: any local + // process can connect to the loopback port and send an arbitrary Host, which would otherwise end + // up as the authority of the uri handed back to the application. + var authority = client.Client.LocalEndPoint is IPEndPoint localEndPoint + ? localEndPoint.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{localEndPoint.Address}]:{localEndPoint.Port}" + : $"{localEndPoint.Address}:{localEndPoint.Port}" + : "127.0.0.1"; + + // Reconstruct a full URI from the request target, so downstream redirect uri handling + // keeps the correct host and port. + return Uri.TryCreate($"http://{authority}{rawTarget}", UriKind.Absolute, out var uri) ? uri : null; + } + + private static async Task SendResponseAsync( + TcpClient client, BrowserResponse response, CancellationToken cancellationToken) + { + var redirect = response.ReadRedirect(); + + if (response.OutputStream.CanSeek) + { + response.OutputStream.Position = 0; + } + + // A redirect carries the target in the Location header and no body. + var contentHeader = redirect is not null + ? $"Location: {redirect.AbsoluteUri}\r\n" + : "Content-Type: text/html; charset=utf-8\r\n"; + + var header = + $"HTTP/1.1 {(int)response.StatusCode} {GetReasonPhrase(response.StatusCode)}\r\n" + + contentHeader + + $"Content-Length: {response.OutputStream.Length}\r\n" + + "Connection: close\r\n\r\n"; + + var headerBytes = Encoding.ASCII.GetBytes(header); + + var stream = client.GetStream(); + await stream.WriteAsync(headerBytes, cancellationToken).ConfigureAwait(false); + if (response.OutputStream.Length > 0) + { + await response.OutputStream.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + } + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + + // Send FIN so the browser sees a clean end-of-stream rather than a potential RST + // when the TcpClient is disposed right after this method returns. + try + { + client.Client.Shutdown(SocketShutdown.Send); + } + catch (SocketException) + { + // Peer already closed the connection. + } + catch (ObjectDisposedException) + { + // Client disposed during a shutdown race. + } + } + + private static string GetReasonPhrase(HttpStatusCode statusCode) + { + return statusCode switch + { + HttpStatusCode.OK => "OK", + HttpStatusCode.Found => "Found", + HttpStatusCode.BadRequest => "Bad Request", + HttpStatusCode.NotFound => "Not Found", + _ => statusCode.ToString() // enum name is only a best-effort reason phrase + }; + } + + private static BrowserResponse BuildDefaultResponse(HttpStatusCode code) + { + var stream = new MemoryStream(); + if (code == HttpStatusCode.OK) + { + stream.Write( + """ + + + + + + Authentication complete + + +

Authentication complete

+

You can close this window and return to the application.

+ + + """u8); + } + else + { + stream.Write(Encoding.UTF8.GetBytes($"{GetReasonPhrase(code)}")); + } + + return new BrowserResponse(stream) { StatusCode = code }; + } +} diff --git a/src/Avalonia.Controls.WebView.Core/Authentication/SystemBrowserWebAuthenticationBroker.cs b/src/Avalonia.Controls.WebView.Core/Authentication/SystemBrowserWebAuthenticationBroker.cs new file mode 100644 index 0000000..526c631 --- /dev/null +++ b/src/Avalonia.Controls.WebView.Core/Authentication/SystemBrowserWebAuthenticationBroker.cs @@ -0,0 +1,129 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace Avalonia.Controls.Authentication; + +/// +/// Authentication flow that opens the request in the user's browser and captures the callback on a loopback HTTP listener. +/// +internal static class SystemBrowserWebAuthenticationBroker +{ + public static async Task AuthenticateAsync( + Uri requestUri, + Uri redirectUri, + TimeSpan timeout, + Func> launcher, + Func? responseFactory, + CancellationToken cancellationToken) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException( + "The system browser authentication mode requires a local HTTP listener, which is not available in the browser."); + } + + ValidateRedirectUri(redirectUri); + + using var listener = new LoopbackHttpListener(redirectUri.IsDefaultPort ? 0 : redirectUri.Port, redirectUri.AbsolutePath); + + var actualRedirectUri = new UriBuilder(redirectUri) { Port = listener.Port }.Uri; + + if (actualRedirectUri != redirectUri) + { + // Coerce requestUri parameter + requestUri = ReplaceRedirectUri(requestUri, actualRedirectUri); + } + + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + // Start accepting before the browser is launched. The callback can arrive as soon as the + // browser opens, and a launcher is not required to return before that happens. + var callbackTask = listener.WaitForCallbackAsync(responseFactory, linkedCts.Token); + + try + { + var success = false; + Exception? error = null; + try + { + success = await launcher(requestUri).ConfigureAwait(false); + } + catch (Exception ex) + { + error = ex; + } + + if (!success || error != null) + { + throw new InvalidOperationException( + $"Failed to open '{requestUri}' in the system browser.", error); + } + + return await callbackTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && + !cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException( + $"Timed out after {timeout} waiting for the authentication callback."); + } + finally + { + if (!callbackTask.IsCompleted) + { + await linkedCts.CancelAsync().ConfigureAwait(false); + _ = callbackTask.ContinueWith(static t => _ = t.Exception, TaskScheduler.Default); + } + } + } + + private static Uri ReplaceRedirectUri(Uri requestUri, Uri redirectUri) + { + var builder = new UriBuilder(requestUri); + + var query = HttpUtility.ParseQueryString(requestUri.Query); + + if (query.GetValues("redirect_uri") is not { Length: 1 }) + { + throw new InvalidOperationException( + "The request URI must contain exactly one 'redirect_uri' query parameter."); + } + + query["redirect_uri"] = redirectUri.AbsoluteUri; + + builder.Query = query.ToString(); + + return builder.Uri; + } + + private static void ValidateRedirectUri(Uri redirectUri) + { + if (!redirectUri.IsAbsoluteUri) + { + throw new ArgumentException( + "Redirect uri must be absolute when using the system browser.", nameof(redirectUri)); + } + + if (redirectUri.Scheme != Uri.UriSchemeHttp) + { + throw new ArgumentException( + $"Redirect uri must use the '{Uri.UriSchemeHttp}' scheme when using the system browser, but was '{redirectUri.Scheme}'.", + nameof(redirectUri)); + } + + if (!IsLoopback(redirectUri)) + { + throw new ArgumentException( + $"Redirect uri host must be a loopback address when using the system browser, but was '{redirectUri.Host}'.", + nameof(redirectUri)); + } + } + + private static bool IsLoopback(Uri redirectUri) => + IPAddress.TryParse(redirectUri.Host, out var address) && IPAddress.IsLoopback(address); +} + From eb087122b674bf3f03d5c8292c48f21bfea01043 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 14 Aug 2026 03:36:50 -0700 Subject: [PATCH 2/9] Implement WebAuthenticatorOptions.Mode selector, with WebAuthenticatorOptions.BrowserOptions --- .../WebViewHelper.cs | 2 +- .../Avalonia.Controls.WebView.csproj | 4 + src/Avalonia.Controls.WebView/README.md | 1 + .../WebAuthenticationBroker.cs | 110 ++++++++++------ .../WebAuthenticatorOptions.cs | 119 ++++++++++++++++++ .../Avalonia.Xpf.Controls.WebView.csproj | 3 + src/Avalonia.Xpf.Controls.WebView/README.md | 1 + 7 files changed, 201 insertions(+), 39 deletions(-) create mode 100644 src/Avalonia.Controls.WebView/WebAuthenticatorOptions.cs diff --git a/src/Avalonia.Controls.WebView.Core/WebViewHelper.cs b/src/Avalonia.Controls.WebView.Core/WebViewHelper.cs index 276edf0..21bb458 100644 --- a/src/Avalonia.Controls.WebView.Core/WebViewHelper.cs +++ b/src/Avalonia.Controls.WebView.Core/WebViewHelper.cs @@ -13,7 +13,7 @@ internal static string BuildWebKitInvokeCSharpActionScript( BuildInvokeCSharpActionScript("window.webkit.messageHandlers." + messageName, stringify: stringify); /// Target object to send message to. - /// Method on the that should be invoked to pass the message. + /// Method on the that should be invoked to pass the message. /// /// Defines if post data should be JSON serialized, /// some backends do that automatically when marshall objects to the C# handlers. diff --git a/src/Avalonia.Controls.WebView/Avalonia.Controls.WebView.csproj b/src/Avalonia.Controls.WebView/Avalonia.Controls.WebView.csproj index a28fd01..07ec2e4 100644 --- a/src/Avalonia.Controls.WebView/Avalonia.Controls.WebView.csproj +++ b/src/Avalonia.Controls.WebView/Avalonia.Controls.WebView.csproj @@ -11,6 +11,10 @@ MIT + + $(DefineConstants);BROWSER + + diff --git a/src/Avalonia.Controls.WebView/README.md b/src/Avalonia.Controls.WebView/README.md index d83c0b1..da5b4a3 100644 --- a/src/Avalonia.Controls.WebView/README.md +++ b/src/Avalonia.Controls.WebView/README.md @@ -39,5 +39,6 @@ Native web dialog that provides a way to display web content in a separate windo ### WebAuthenticationBroker WebAuthenticationBroker is a utility class that facilitates OAuth and other web-based authentication flows by providing a secure way to handle web authentication in desktop applications. +Set `WebAuthenticatorMode.Browser` to run the flow in the user's default browser and capture the callback on a local loopback HTTP listener, which is required by providers that reject embedded webviews. **Documentation**: https://docs.avaloniaui.net/accelerate/components/webview/webauthenticationbroker diff --git a/src/Avalonia.Controls.WebView/WebAuthenticationBroker.cs b/src/Avalonia.Controls.WebView/WebAuthenticationBroker.cs index d1da46c..19ff5e4 100644 --- a/src/Avalonia.Controls.WebView/WebAuthenticationBroker.cs +++ b/src/Avalonia.Controls.WebView/WebAuthenticationBroker.cs @@ -1,10 +1,13 @@ using System; +using System.Threading; using System.Threading.Tasks; +using Avalonia.Controls.Authentication; using Avalonia.Platform; using Core = Avalonia.Controls; #if WPF using AvaloniaUI.Xpf.WpfAbstractions; using Avalonia.Controls; +using AvTopLevel = Avalonia.Controls.TopLevel; #elif AVALONIA using AvTopLevel = Avalonia.Controls.TopLevel; #endif @@ -24,7 +27,7 @@ public static class WebAuthenticationBroker /// Starts an authentication flow by navigating to the specified start URI and monitoring for navigation to the end URI. /// /// Platform is not supported. - /// Operation was cancelled programmatically or by user. + /// Operation was canceled programmatically or by user. public static async Task AuthenticateAsync #if WPF (System.Windows.Window topLevel, WebAuthenticatorOptions options) @@ -32,47 +35,101 @@ public static async Task AuthenticateAsync (AvTopLevel topLevel, WebAuthenticatorOptions options) #endif { - var supportsNativeWebDialog = - OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || - OperatingSystem.IsAndroid(); + var mode = GetEffectiveMode(options); - if (!(supportsNativeWebDialog & options.PreferNativeWebDialog) #if WPF - && XpfWpfAbstraction.GetAvaloniaTopLevelForWindow(topLevel) is { } avTopLevel + var avTopLevel = XpfWpfAbstraction.GetAvaloniaTopLevelForWindow(topLevel); #elif AVALONIA - && topLevel is var avTopLevel + var avTopLevel = topLevel; #endif - ) + + if (avTopLevel is null) + { + throw new ArgumentNullException(nameof(topLevel)); + } + + switch (mode) { +#pragma warning disable CA1416 + case WebAuthenticatorMode.Browser: + return await AuthenticateSystemBrowserAsync(avTopLevel, options); #if ANDROID - if (OperatingSystem.IsAndroid()) + case WebAuthenticatorMode.System when OperatingSystem.IsAndroid(): { var uri = await Core.Android.AndroidWebAuthenticationBroker.AuthenticateAsync(avTopLevel, options.RequestUri, options.RedirectUri); return new WebAuthenticationResult(uri); } #else - if ((OperatingSystem.IsIOSVersionAtLeast(13, 0) || OperatingSystem.IsMacOSVersionAtLeast(10, 15))) + case WebAuthenticatorMode.System when (OperatingSystem.IsIOSVersionAtLeast(13, 0) || OperatingSystem.IsMacOSVersionAtLeast(10, 15)): { var uri = await Core.Macios.MaciosWebAuthenticationBroker.AuthenticateAsync(avTopLevel, options.RequestUri, options.RedirectUri.Scheme, options.NonPersistent); return new WebAuthenticationResult(uri); } - else if (OperatingSystem.IsBrowser()) + case WebAuthenticatorMode.System when OperatingSystem.IsBrowser(): { var uri = await Core.Browser.BrowserWebAuthenticationBroker.AuthenticateAsync(avTopLevel, options.RequestUri, options.RedirectUri); return new WebAuthenticationResult(uri); } #endif + case WebAuthenticatorMode.NativeWebDialog: + return await AuthenticateDialogAsync(topLevel, options); + default: + throw new PlatformNotSupportedException(); +#pragma warning restore CA1416 } + } + + private static WebAuthenticatorMode GetEffectiveMode(WebAuthenticatorOptions options) + { +#pragma warning disable CA1416 +#pragma warning disable CS0618 + var supportsNativeWebDialog = OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || + OperatingSystem.IsMacOS() || OperatingSystem.IsAndroid(); + var supportsSystem = OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || + OperatingSystem.IsAndroid() || OperatingSystem.IsBrowser(); + var supportsBrowserLauncher = !OperatingSystem.IsBrowser(); // can't launch browser from the browser, duh. - if (supportsNativeWebDialog) + var mode = options is { Mode: WebAuthenticatorMode.Auto, PreferNativeWebDialog: true } ? + WebAuthenticatorMode.NativeWebDialog : + options.Mode; + + return mode switch { - return await AuthenticateDialogAsync(topLevel, options); - } + WebAuthenticatorMode.Auto when supportsSystem => WebAuthenticatorMode.System, + WebAuthenticatorMode.Auto when supportsNativeWebDialog => WebAuthenticatorMode.NativeWebDialog, + WebAuthenticatorMode.Auto when supportsBrowserLauncher => WebAuthenticatorMode.Browser, + + WebAuthenticatorMode.System when supportsSystem => WebAuthenticatorMode.System, + WebAuthenticatorMode.NativeWebDialog when supportsNativeWebDialog => WebAuthenticatorMode.NativeWebDialog, + WebAuthenticatorMode.Browser when supportsBrowserLauncher => WebAuthenticatorMode.Browser, + + _ => throw new PlatformNotSupportedException( + $"WebAuthenticatorMode.{mode} is not supported on the current platform") + }; +#pragma warning restore CS0618 +#pragma warning restore CA1416 + } + + private static async Task AuthenticateSystemBrowserAsync( + AvTopLevel topLevel, WebAuthenticatorOptions options) + { + var browserOptions = options.BrowserOptions ?? new BrowserOptions(); + + var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync( + options.RequestUri, + options.RedirectUri, + browserOptions.Timeout, + uri => topLevel.Launcher.LaunchUriAsync(uri), + browserOptions.ResponseHandler is not null ? + (uri, response) => browserOptions + .ResponseHandler.Invoke(new WebAuthenticationResult(uri), response) : + null, + CancellationToken.None); - throw new PlatformNotSupportedException(); + return new WebAuthenticationResult(callbackUri); } private static async Task AuthenticateDialogAsync @@ -152,29 +209,6 @@ private static NativeWebDialog DefaultFactory() } } - /// - /// Authentication options that control the broker's behavior. - /// - /// The initial URI that starts the authentication flow. - /// URI that indicates the completion of the authentication flow. - public record WebAuthenticatorOptions(Uri RequestUri, Uri RedirectUri) - { - /// - /// If true, WebAuthenticationBroker will avoid platform specific implementation option, and will use webview dialog window. - /// - public bool PreferNativeWebDialog { get; init; } - - /// - /// Hint for the platform implementation to not store any session data persistently. - /// - public bool NonPersistent { get; init; } - - /// - /// Callback that can be used to override NativeWebDialog creation when WebAuthenticationBroker uses dialog implementation instead of system auth APIs. - /// - public Func? NativeWebDialogFactory { get; init; } - } - /// The response URI containing authentication data. public record WebAuthenticationResult(Uri CallbackUri); } diff --git a/src/Avalonia.Controls.WebView/WebAuthenticatorOptions.cs b/src/Avalonia.Controls.WebView/WebAuthenticatorOptions.cs new file mode 100644 index 0000000..f502f3c --- /dev/null +++ b/src/Avalonia.Controls.WebView/WebAuthenticatorOptions.cs @@ -0,0 +1,119 @@ +using System; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Avalonia.Controls.Authentication; + +#if AVALONIA +namespace Avalonia.Controls; +#elif WPF +namespace Avalonia.Xpf.Controls; +#endif + +/// +/// Authentication options that control the broker's behavior. +/// +/// The initial URI that starts the authentication flow. +/// URI that indicates the completion of the authentication flow. +public record WebAuthenticatorOptions(Uri RequestUri, Uri RedirectUri) +{ + /// + /// Implementation used to run the flow. + /// + public WebAuthenticatorMode Mode { get; init; } + + /// + /// If true, WebAuthenticationBroker will avoid platform specific implementation option, and will use webview dialog window. + /// + [Obsolete("Use Mode = WebAuthenticatorMode.NativeWebDialog instead.")] + public bool PreferNativeWebDialog { get; init; } + + /// + /// Hint for the platform implementation to not store any session data persistently. + /// + /// + /// Ignored by , which uses the user's own browser session. + /// + public bool NonPersistent { get; init; } + + /// + /// Callback that can be used to override NativeWebDialog creation when WebAuthenticationBroker uses dialog implementation instead of system auth APIs. + /// + public Func? NativeWebDialogFactory { get; init; } + + /// + /// Options used when is . + /// + public BrowserOptions? BrowserOptions { get; init; } +} + +/// +/// Selects which implementation uses to run the flow. +/// +public enum WebAuthenticatorMode +{ + /// + /// Automatically selects the most appropriate authentication mode for the current platform. + /// + Auto, + + /// + /// Uses the platform's native web authentication APIs when available. + /// + [SupportedOSPlatform("macos")] + [SupportedOSPlatform("ios")] + [SupportedOSPlatform("android")] + [SupportedOSPlatform("browser")] + System, + + /// + /// Displays the authentication flow in a containing an embedded web view. + /// + [SupportedOSPlatform("windows")] + [SupportedOSPlatform("macos")] + [SupportedOSPlatform("android")] + [SupportedOSPlatform("linux")] + NativeWebDialog, + + /// + /// Opens the authentication flow in the user's default web browser and uses a local HTTP listener to receive the redirect. + /// + /// + /// Requires to be an http loopback address. + /// + [UnsupportedOSPlatform("browser")] + Browser +} + +/// +/// Options for . +/// +public record BrowserOptions +{ + /// + /// How long to wait for the callback before the flow is canceled. + /// Defaults to 5 minutes. + /// + public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets a callback used to customize the HTTP response sent to the browser after the authentication callback is received. + /// + /// + /// If not specified, a default response is sent to the browser. + /// + public BrowserResponseHandler? ResponseHandler { get; init; } + + /// + /// Handles the HTTP response sent to the browser after the authentication callback + /// is received. + /// + /// + /// The result of the authentication flow. + /// + /// + /// The response used to configure the HTTP response sent to the browser. + /// + public delegate Task BrowserResponseHandler( + WebAuthenticationResult result, + BrowserResponse response); +} diff --git a/src/Avalonia.Xpf.Controls.WebView/Avalonia.Xpf.Controls.WebView.csproj b/src/Avalonia.Xpf.Controls.WebView/Avalonia.Xpf.Controls.WebView.csproj index 083b61a..d4af8bc 100644 --- a/src/Avalonia.Xpf.Controls.WebView/Avalonia.Xpf.Controls.WebView.csproj +++ b/src/Avalonia.Xpf.Controls.WebView/Avalonia.Xpf.Controls.WebView.csproj @@ -39,6 +39,9 @@ WebAuthenticationBroker.cs + + WebAuthenticatorOptions.cs + WindowNativeWebViewDialog.cs diff --git a/src/Avalonia.Xpf.Controls.WebView/README.md b/src/Avalonia.Xpf.Controls.WebView/README.md index 7a1062c..92d8001 100644 --- a/src/Avalonia.Xpf.Controls.WebView/README.md +++ b/src/Avalonia.Xpf.Controls.WebView/README.md @@ -44,5 +44,6 @@ Native web dialog that provides a way to display web content in a separate windo ### WebAuthenticationBroker WebAuthenticationBroker is a utility class that facilitates OAuth and other web-based authentication flows by providing a secure way to handle web authentication in desktop applications. +Set `WebAuthenticatorMode.Browser` to run the flow in the user's default browser and capture the callback on a local loopback HTTP listener, which is required by providers that reject embedded webviews. **Documentation**: https://docs.avaloniaui.net/accelerate/components/webview/webauthenticationbroker From 8b35d0dc6c13292ed95fdbeefca812283fefa59e Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 14 Aug 2026 03:37:51 -0700 Subject: [PATCH 3/9] Add sample --- Avalonia.Controls.WebView.slnx | 7 +++++++ .../MainView.axaml | 6 ++++++ .../MainView.axaml.cs | 16 ++++++++++++++-- .../MainView.xaml | 6 ++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Avalonia.Controls.WebView.slnx b/Avalonia.Controls.WebView.slnx index ab5330a..4d7efad 100644 --- a/Avalonia.Controls.WebView.slnx +++ b/Avalonia.Controls.WebView.slnx @@ -25,6 +25,13 @@ + + + + + + + diff --git a/samples/Avalonia.Controls.WebView.Samples/MainView.axaml b/samples/Avalonia.Controls.WebView.Samples/MainView.axaml index e75d8c3..2541c54 100644 --- a/samples/Avalonia.Controls.WebView.Samples/MainView.axaml +++ b/samples/Avalonia.Controls.WebView.Samples/MainView.axaml @@ -125,6 +125,12 @@ + + + + +