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..ccf4302 100644
--- a/samples/Avalonia.Controls.WebView.Samples/MainView.axaml
+++ b/samples/Avalonia.Controls.WebView.Samples/MainView.axaml
@@ -125,6 +125,13 @@
+
+
+
+
+
+ WebAuthenticatorMode.System,
+ 2 => WebAuthenticatorMode.NativeWebDialog,
+ 3 => WebAuthenticatorMode.Browser,
+ _ => WebAuthenticatorMode.Auto
+ },
+#pragma warning restore CA1416
+ BrowserOptions = new BrowserOptions
+ {
+ // The loopback listener is reachable by any local process, so ignore anything that
+ // doesn't carry the state we sent instead of letting it end the flow.
+ CallbackFilter = result =>
+ HttpUtility.ParseQueryString(result.CallbackUri.Query)["state"] == state
+ }
+ };
var result = await WebAuthenticationBroker.AuthenticateAsync(topLevel!, options);
@@ -236,7 +264,7 @@ private static (string requestUri, string redirectUri) GetGoogleAuth()
"com.AvaloniaUI.WebView.Samples:/oauth2redirect" :
OperatingSystem.IsBrowser() ?
href?.TrimEnd('/') + "/oauth2redirect" :
- "http://localhost";
+ "http://127.0.0.1";
var clientId = OperatingSystem.IsIOS() ?
"457602913817-kd2547t40mrvqi63c4m7lphs5s6s5lt2.apps.googleusercontent.com" :
OperatingSystem.IsAndroid() ?
@@ -248,6 +276,7 @@ private static (string requestUri, string redirectUri) GetGoogleAuth()
var requestUri = "https://accounts.google.com/o/oauth2/auth?response_type=code&access_type=offline&scope=openid";
requestUri += "&client_id=" + clientId;
requestUri += "&redirect_uri=" + redirectUri;
+ requestUri += "&state=" + Guid.NewGuid().ToString("N");
return (requestUri, redirectUri);
}
diff --git a/samples/Avalonia.Xpf.Controls.WebView.Samples/MainView.xaml b/samples/Avalonia.Xpf.Controls.WebView.Samples/MainView.xaml
index e741810..f275bef 100644
--- a/samples/Avalonia.Xpf.Controls.WebView.Samples/MainView.xaml
+++ b/samples/Avalonia.Xpf.Controls.WebView.Samples/MainView.xaml
@@ -132,6 +132,12 @@
+
+
+
+
+
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..4c82a25
--- /dev/null
+++ b/src/Avalonia.Controls.WebView.Core/Authentication/BrowserResponse.cs
@@ -0,0 +1,50 @@
+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.
+ /// Sets to .
+ ///
+ /// Absolute uri to redirect the browser to.
+ /// is .
+ /// is not absolute.
+ public void Redirect(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+
+ if (!uri.IsAbsoluteUri)
+ {
+ throw new ArgumentException("Redirect uri must be absolute.", nameof(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..d3f5227
--- /dev/null
+++ b/src/Avalonia.Controls.WebView.Core/Authentication/LoopbackHttpListener.cs
@@ -0,0 +1,263 @@
+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 _host;
+ private readonly string _redirectPath;
+
+ ///
+ /// Starts a listener on the loopback interface.
+ ///
+ ///
+ /// Redirect uri to serve.
+ /// Its host selects the loopback address to bind and is reported back in the callback uri.
+ /// Its port is used when specified, otherwise the OS allocates a free one.
+ ///
+ public LoopbackHttpListener(Uri redirectUri)
+ {
+ _host = redirectUri.Host;
+ _redirectPath = redirectUri.AbsolutePath;
+
+ var address = ResolveBindAddress(redirectUri);
+ var port = redirectUri.IsDefaultPort ? 0 : redirectUri.Port;
+
+ _listener = new TcpListener(address, port) { ExclusiveAddressUse = true };
+
+ try
+ {
+ _listener.Start();
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Failed to start the local callback listener on {address}:{port}.", ex);
+ }
+ }
+
+ private static IPAddress ResolveBindAddress(Uri redirectUri) =>
+ IPAddress.TryParse(redirectUri.DnsSafeHost, out var address) && IPAddress.IsLoopback(address)
+ ? address
+ : IPAddress.Loopback;
+
+ ///
+ /// Port the listener is actually bound to.
+ ///
+ public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
+
+ ///
+ /// Number of requests to the redirect path that callbackFilter rejected.
+ ///
+ public int RejectedCallbackCount { get; private set; }
+
+ public async Task WaitForCallbackAsync(
+ Func? callbackFilter,
+ 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;
+
+ if (found && callbackFilter is not null && !callbackFilter(requestUri))
+ {
+ found = false;
+ RejectedCallbackCount++;
+ }
+
+ 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 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 is the host the application configured plus the port actually bound.
+ // It never comes from the request's Host header.
+ return Uri.TryCreate($"http://{_host}:{Port}{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..fd7567a
--- /dev/null
+++ b/src/Avalonia.Controls.WebView.Core/Authentication/SystemBrowserWebAuthenticationBroker.cs
@@ -0,0 +1,131 @@
+using System;
+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? callbackFilter,
+ 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);
+
+ 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(callbackFilter, 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)
+ {
+ // A filter that never matches otherwise presents as a silent wait until the timeout.
+ var rejected = listener.RejectedCallbackCount > 0
+ ? $" {listener.RejectedCallbackCount} callback(s) were rejected by the callback filter."
+ : "";
+
+ throw new OperationCanceledException(
+ $"Timed out after {timeout} waiting for the authentication callback.{rejected}");
+ }
+ 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 (!redirectUri.IsLoopback)
+ {
+ throw new ArgumentException(
+ $"Redirect uri host must be a loopback address or 'localhost' when using the system browser, but was '{redirectUri.Host}'.",
+ nameof(redirectUri));
+ }
+ }
+}
+
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..c489e80 100644
--- a/src/Avalonia.Controls.WebView/README.md
+++ b/src/Avalonia.Controls.WebView/README.md
@@ -39,5 +39,8 @@ 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.
+
+That listener accepts connections from any process on the machine, so the returned `CallbackUri` is untrusted input: validate its `code`, `state` and `error` parameters, and use PKCE.
**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..40f251a 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,104 @@ 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.CallbackFilter is { } filter ?
+ uri => filter.Invoke(new WebAuthenticationResult(uri)) :
+ null,
+ 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 +212,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..c5eafb6
--- /dev/null
+++ b/src/Avalonia.Controls.WebView/WebAuthenticatorOptions.cs
@@ -0,0 +1,159 @@
+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.
+ ///
+ ///
+ /// The redirect is received on a local socket.
+ /// Any process on the machine can connect to it, so is untrusted input.
+ /// The caller must validate its code, state and error parameters.
+ ///
+ ///
+ /// PKCE is strongly recommended (RFC 8252, section 8.1).
+ /// It is what makes an injected authorization code unusable at the token endpoint.
+ /// Use to keep the listener waiting when a request does not belong to the flow.
+ ///
+ ///
+ [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; }
+
+ ///
+ /// Gets or sets a callback that decides whether a request to the redirect path belongs to this authentication flow.
+ ///
+ ///
+ ///
+ /// The local listener accepts connections from any process on the machine.
+ /// A request that reaches the redirect path is therefore not necessarily the browser's.
+ ///
+ ///
+ /// The usual implementation compares the state query parameter against the value sent in the authorization request.
+ /// The caller still has to check code, state and error on the returned .
+ ///
+ ///
+ public BrowserCallbackFilter? CallbackFilter { 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);
+
+ ///
+ /// Decides whether a request received on the redirect path belongs to the authentication flow.
+ ///
+ ///
+ /// The candidate result.
+ /// Its is unvalidated input that any local process can produce.
+ ///
+ ///
+ /// to complete the flow with ;
+ /// to reject it and keep waiting.
+ ///
+ public delegate bool BrowserCallbackFilter(WebAuthenticationResult result);
+}
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..81379ff 100644
--- a/src/Avalonia.Xpf.Controls.WebView/README.md
+++ b/src/Avalonia.Xpf.Controls.WebView/README.md
@@ -44,5 +44,8 @@ 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.
+
+That listener accepts connections from any process on the machine, so the returned `CallbackUri` is untrusted input: validate its `code`, `state` and `error` parameters, and use PKCE.
**Documentation**: https://docs.avaloniaui.net/accelerate/components/webview/webauthenticationbroker
diff --git a/tests/Avalonia.Controls.WebView.Tests/LoopbackHttpListenerTests.cs b/tests/Avalonia.Controls.WebView.Tests/LoopbackHttpListenerTests.cs
new file mode 100644
index 0000000..b222020
--- /dev/null
+++ b/tests/Avalonia.Controls.WebView.Tests/LoopbackHttpListenerTests.cs
@@ -0,0 +1,422 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls.Authentication;
+using Xunit;
+
+namespace Avalonia.Controls.WebView.Tests;
+
+public class LoopbackHttpListenerTests
+{
+ private const string RedirectPath = "/callback";
+
+ private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30);
+
+ private static Func Html(string body) =>
+ (_, response) =>
+ {
+ response.StatusCode = HttpStatusCode.OK;
+ return WriteAsync(response, body);
+ };
+
+ private static async Task WriteAsync(BrowserResponse response, string body)
+ {
+ var bytes = Encoding.UTF8.GetBytes(body);
+ await response.OutputStream.WriteAsync(bytes);
+ }
+
+ [Fact]
+ public async Task Should_Return_Uri_With_Query_For_Matching_Path()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("ok"), cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc&state=xyz", cts.Token);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal(RedirectPath, uri.AbsolutePath);
+ Assert.Equal("?code=abc&state=xyz", uri.Query);
+ Assert.Equal(listener.Port, uri.Port);
+ }
+
+ [Theory]
+ [InlineData("localhost")]
+ [InlineData("127.0.0.1")]
+ public async Task Should_Report_The_Configured_Host_In_The_Callback_Uri(string host)
+ {
+ // A redirect uri written as "localhost" must come back as "localhost": the token exchange has to
+ // present the same redirect_uri string that the authorization request carried.
+ using var listener = new LoopbackHttpListener(new Uri($"http://{host}{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("ok"), cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal(host, uri.Host);
+ Assert.Equal(listener.Port, uri.Port);
+ }
+
+ [Fact]
+ public async Task Should_Bind_IPv6_Loopback_For_An_IPv6_Redirect_Uri()
+ {
+ Assert.SkipUnless(Socket.OSSupportsIPv6, "The machine has no IPv6 stack.");
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://[::1]{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("ok"), cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://[::1]:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal("[::1]", uri.Host);
+ Assert.Equal(listener.Port, uri.Port);
+ }
+
+ [Fact]
+ public async Task Should_Allocate_A_Free_Port_When_Zero_Is_Requested()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+
+ Assert.NotEqual(0, listener.Port);
+
+ // The allocated port must be the one actually accepting connections.
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, listener.Port, TestContext.Current.CancellationToken);
+ Assert.True(client.Connected);
+ }
+
+ [Fact]
+ public async Task Should_Send_A_Default_Success_Page_When_No_Handler_Is_Provided()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, null, cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
+ Assert.Contains("Authentication complete", await response.Content.ReadAsStringAsync(cts.Token));
+
+ await waitTask;
+ }
+
+ [Fact]
+ public async Task Should_Reply_404_For_Non_Matching_Path_And_Keep_Listening()
+ {
+ var handlerCalls = 0;
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(
+ null,
+ (_, response) =>
+ {
+ Interlocked.Increment(ref handlerCalls);
+ return WriteAsync(response, "ok");
+ },
+ cts.Token);
+
+ using var http = new HttpClient();
+
+ // Favicon prefetch: should receive 404, listener keeps running.
+ using var faviconResponse = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}/favicon.ico", cts.Token);
+ Assert.Equal(HttpStatusCode.NotFound, faviconResponse.StatusCode);
+ Assert.False(waitTask.IsCompleted, "Listener should keep running after 404.");
+
+ // The user's handler must not observe requests that are not the callback.
+ Assert.Equal(0, Volatile.Read(ref handlerCalls));
+
+ // Actual callback follows.
+ using var callbackResponse = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, callbackResponse.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal(RedirectPath, uri.AbsolutePath);
+ Assert.Equal(1, Volatile.Read(ref handlerCalls));
+ }
+
+ [Fact]
+ public async Task Should_Send_Html_Body_From_Response_Handler()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("custom body"), cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+
+ Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
+ Assert.Equal("utf-8", response.Content.Headers.ContentType?.CharSet);
+ Assert.Equal("custom body", await response.Content.ReadAsStringAsync(cts.Token));
+
+ await waitTask;
+ }
+
+ [Fact]
+ public async Task Should_Pass_The_Callback_Uri_To_The_Response_Handler()
+ {
+ Uri? handlerUri = null;
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(
+ null,
+ (uri, response) =>
+ {
+ handlerUri = uri;
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ return Task.CompletedTask;
+ },
+ cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal(uri, handlerUri);
+ }
+
+ [Fact]
+ public async Task Should_Send_Redirect_When_Response_Is_Configured_To_Redirect()
+ {
+ var location = new Uri("https://example.com/signed-in");
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(
+ null,
+ (_, response) =>
+ {
+ response.Redirect(location);
+
+ // Redirecting must select the status code on its own.
+ Assert.Equal(HttpStatusCode.Found, response.StatusCode);
+ return Task.CompletedTask;
+ },
+ cts.Token);
+
+ using var handler = new HttpClientHandler { AllowAutoRedirect = false };
+ using var http = new HttpClient(handler);
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+
+ Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
+ Assert.Equal(location, response.Headers.Location);
+ Assert.Empty(await response.Content.ReadAsStringAsync(cts.Token));
+
+ await waitTask;
+ }
+
+ [Fact]
+ public async Task Should_Ignore_Absolute_Form_Request_Target()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("ok"), cts.Token);
+
+ // An absolute-form target must not be concatenated into a spoofed callback uri.
+ await SendRawRequestAsync(listener.Port,
+ $"GET http://evil.com{RedirectPath}?code=abc HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
+ cts.Token);
+
+ Assert.False(waitTask.IsCompleted, "Absolute-form target should not complete the flow.");
+
+ // A well formed request still completes it.
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=real", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal("127.0.0.1", uri.Host);
+ Assert.Equal("?code=real", uri.Query);
+ }
+
+ [Theory]
+ [InlineData("evil.example")]
+ [InlineData("user@evil.example")]
+ [InlineData("127.0.0.1:1")]
+ public async Task Should_Ignore_The_Host_Header(string host)
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(null, Html("ok"), cts.Token);
+
+ // Any local process can connect to the port and claim any authority it likes; the callback
+ // uri handed to the application must describe the socket the request actually arrived on.
+ await SendRawRequestAsync(listener.Port,
+ $"GET {RedirectPath}?code=abc HTTP/1.1\r\nHost: {host}\r\n\r\n",
+ cts.Token);
+
+ var uri = await waitTask;
+ Assert.Equal("127.0.0.1", uri.Host);
+ Assert.Equal(listener.Port, uri.Port);
+ Assert.Empty(uri.UserInfo);
+ Assert.Equal("?code=abc", uri.Query);
+ }
+
+ [Fact]
+ public async Task Should_Reject_An_Invalid_Redirect_Uri()
+ {
+ Exception? nullException = null;
+ Exception? relativeException = null;
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(
+ null,
+ (_, response) =>
+ {
+ nullException = Record.Exception(() => response.Redirect(null!));
+ relativeException = Record.Exception(() => response.Redirect(new Uri("/x", UriKind.Relative)));
+
+ // A rejected redirect must leave the response untouched.
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ return WriteAsync(response, "ok");
+ },
+ cts.Token);
+
+ using var http = new HttpClient();
+ using var response = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=abc", cts.Token);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("ok", await response.Content.ReadAsStringAsync(cts.Token));
+
+ await waitTask;
+
+ _ = Assert.IsType(nullException);
+ Assert.Equal("uri", Assert.IsType(relativeException).ParamName);
+ }
+
+ [Fact]
+ public async Task Should_Reject_A_Filtered_Callback_And_Keep_Listening()
+ {
+ var handlerCalls = 0;
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(
+ uri => uri.Query.Contains("state=mine", StringComparison.Ordinal),
+ (_, response) =>
+ {
+ Interlocked.Increment(ref handlerCalls);
+ return WriteAsync(response, "ok");
+ },
+ cts.Token);
+
+ using var http = new HttpClient();
+
+ // A callback that isn't ours must be indistinguishable from a request to an unrelated path.
+ using var rejected = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=evil&state=theirs", cts.Token);
+ Assert.Equal(HttpStatusCode.NotFound, rejected.StatusCode);
+ Assert.False(waitTask.IsCompleted, "A rejected callback must not end the flow.");
+ Assert.Equal(0, Volatile.Read(ref handlerCalls));
+ Assert.Equal(1, listener.RejectedCallbackCount);
+
+ // The real callback still completes it.
+ using var accepted = await http.GetAsync(
+ $"http://127.0.0.1:{listener.Port}{RedirectPath}?code=real&state=mine", cts.Token);
+ Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
+
+ var uri = await waitTask;
+ Assert.Equal("?code=real&state=mine", uri.Query);
+ Assert.Equal(1, Volatile.Read(ref handlerCalls));
+ Assert.Equal(1, listener.RejectedCallbackCount);
+ }
+
+ [Fact]
+ public async Task Should_Surface_An_Exception_Thrown_By_The_Filter()
+ {
+ var expected = new InvalidOperationException("bad filter");
+
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource(s_timeout);
+
+ var waitTask = listener.WaitForCallbackAsync(_ => throw expected, null, cts.Token);
+
+ // The listener faults before writing a response, so send raw rather than waiting on HttpClient
+ // for a reply that never comes.
+ await SendRawRequestAsync(listener.Port,
+ $"GET {RedirectPath}?code=abc HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
+ cts.Token);
+
+ Assert.Same(expected, await Assert.ThrowsAsync(() => waitTask));
+ }
+
+ [Fact]
+ public async Task Should_Respect_Cancellation()
+ {
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+ using var cts = new CancellationTokenSource();
+
+ var waitTask = listener.WaitForCallbackAsync(null, null, cts.Token);
+
+ await cts.CancelAsync();
+
+ _ = await Assert.ThrowsAnyAsync(() => waitTask);
+ }
+
+ [Fact]
+ public void Should_Fail_When_The_Port_Is_Already_In_Use()
+ {
+ using var first = new LoopbackHttpListener(new Uri($"http://127.0.0.1{RedirectPath}"));
+
+ _ = Assert.Throws(
+ () => new LoopbackHttpListener(new Uri($"http://127.0.0.1:{first.Port}{RedirectPath}")));
+ }
+
+ private static async Task SendRawRequestAsync(int port, string request, CancellationToken cancellationToken)
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port, cancellationToken);
+
+ var stream = client.GetStream();
+ var bytes = Encoding.ASCII.GetBytes(request);
+ await stream.WriteAsync(bytes, cancellationToken);
+ await stream.FlushAsync(cancellationToken);
+
+ // Read until the listener closes the connection, so the request is fully processed
+ // before the assertions run.
+ using var drain = new MemoryStream();
+ await stream.CopyToAsync(drain, cancellationToken);
+ }
+}
diff --git a/tests/Avalonia.Controls.WebView.Tests/SystemBrowserWebAuthenticationBrokerTests.cs b/tests/Avalonia.Controls.WebView.Tests/SystemBrowserWebAuthenticationBrokerTests.cs
new file mode 100644
index 0000000..5ea9b54
--- /dev/null
+++ b/tests/Avalonia.Controls.WebView.Tests/SystemBrowserWebAuthenticationBrokerTests.cs
@@ -0,0 +1,444 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Web;
+using Avalonia.Controls.Authentication;
+using Xunit;
+
+namespace Avalonia.Controls.WebView.Tests;
+
+///
+/// Covers the loopback browser flow directly, because resolves the
+/// browser launcher from TopLevel.Launcher and therefore cannot be driven end to end from a test.
+///
+public class SystemBrowserWebAuthenticationBrokerTests
+{
+ private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30);
+
+ private static readonly Uri s_defaultRedirectUri = new("http://127.0.0.1/callback");
+
+ [Fact]
+ public async Task Should_Complete_Flow_And_Preserve_The_Other_Request_Parameters()
+ {
+ var requestUri = new Uri(
+ "http://input.com/authorize?client_id=abc&scope=openid&state=xyz&redirect_uri=" +
+ Uri.EscapeDataString(s_defaultRedirectUri.AbsoluteUri));
+
+ Uri? launchedUri = null;
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ requestUri,
+ s_defaultRedirectUri,
+ s_timeout,
+ async uri =>
+ {
+ launchedUri = uri;
+ await GetAsync(RedirectUriOf(uri) + "?code=123&state=xyz");
+ return true;
+ },
+ null,
+ null,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(launchedUri);
+
+ // The dynamically allocated port must be pushed into the request's redirect_uri...
+ var actualRedirectUri = new Uri(RedirectUriOf(launchedUri));
+ Assert.Equal("127.0.0.1", actualRedirectUri.Host);
+ Assert.Equal("/callback", actualRedirectUri.AbsolutePath);
+ Assert.NotEqual(80, actualRedirectUri.Port);
+
+ // ...without dropping the rest of the authorization request.
+ var query = HttpUtility.ParseQueryString(launchedUri.Query);
+ Assert.Equal("abc", query["client_id"]);
+ Assert.Equal("openid", query["scope"]);
+ Assert.Equal("xyz", query["state"]);
+ Assert.Equal("input.com", launchedUri.Host);
+ Assert.Equal("/authorize", launchedUri.AbsolutePath);
+
+ Assert.Equal("/callback", callbackUri.AbsolutePath);
+ Assert.Equal("?code=123&state=xyz", callbackUri.Query);
+ Assert.Equal(actualRedirectUri.Port, callbackUri.Port);
+ }
+
+ [Fact]
+ public async Task Should_Bind_The_Explicit_Port_Of_The_Redirect_Uri()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+ var requestUri = new Uri("http://input.com/authorize?client_id=abc");
+
+ Uri? launchedUri = null;
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ requestUri,
+ redirectUri,
+ s_timeout,
+ async uri =>
+ {
+ launchedUri = uri;
+ await GetAsync($"{redirectUri}?code=123");
+ return true;
+ },
+ null,
+ null,
+ TestContext.Current.CancellationToken);
+
+ // An explicit port needs no coercion, so the request uri is passed through untouched.
+ Assert.Equal(requestUri, launchedUri);
+ Assert.Equal(port, callbackUri.Port);
+ Assert.Equal("?code=123", callbackUri.Query);
+ }
+
+ [Fact]
+ public async Task Should_Accept_A_Localhost_Redirect_Uri()
+ {
+ var redirectUri = new Uri("http://localhost/callback");
+ var requestUri = new Uri("http://input.com/authorize?client_id=abc&redirect_uri=" +
+ Uri.EscapeDataString(redirectUri.AbsoluteUri));
+
+ Uri? launchedUri = null;
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ requestUri,
+ redirectUri,
+ s_timeout,
+ async uri =>
+ {
+ launchedUri = uri;
+ await GetAsync(RedirectUriOf(uri) + "?code=123");
+ return true;
+ },
+ null,
+ null,
+ TestContext.Current.CancellationToken);
+
+ Assert.NotNull(launchedUri);
+
+ // The rewritten redirect_uri and the callback must both keep the configured host, so the token
+ // exchange can present the same string the authorization request carried.
+ var actualRedirectUri = new Uri(RedirectUriOf(launchedUri));
+ Assert.Equal("localhost", actualRedirectUri.Host);
+ Assert.Equal("localhost", callbackUri.Host);
+ Assert.Equal(actualRedirectUri.Port, callbackUri.Port);
+ Assert.Equal("?code=123", callbackUri.Query);
+ }
+
+ [Fact]
+ public async Task Should_Send_The_Response_Produced_By_The_Response_Handler()
+ {
+ Uri? handlerUri = null;
+ string? body = null;
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?redirect_uri=" +
+ Uri.EscapeDataString(s_defaultRedirectUri.AbsoluteUri)),
+ s_defaultRedirectUri,
+ s_timeout,
+ async uri =>
+ {
+ body = await GetAsync(RedirectUriOf(uri) + "?code=123");
+ return true;
+ },
+ null,
+ async (uri, response) =>
+ {
+ handlerUri = uri;
+ await response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes("done"));
+ },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal("done", body);
+ Assert.Equal(callbackUri, handlerUri);
+ }
+
+ [Fact]
+ public async Task Should_Ignore_A_Forged_Callback_That_Arrives_Before_The_Real_One()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+ var rejected = new List();
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc&state=mine"),
+ redirectUri,
+ s_timeout,
+ async _ =>
+ {
+ // Any local process can reach the port and race the browser to it.
+ await GetAsync($"{redirectUri}?code=evil&state=theirs");
+ await GetAsync($"{redirectUri}?code=real&state=mine");
+ return true;
+ },
+ uri =>
+ {
+ var accepted = HttpUtility.ParseQueryString(uri.Query)["state"] == "mine";
+ if (!accepted)
+ {
+ rejected.Add(uri);
+ }
+
+ return accepted;
+ },
+ null,
+ TestContext.Current.CancellationToken);
+
+ // The forged callback must neither complete nor kill the flow.
+ Assert.Equal("?code=real&state=mine", callbackUri.Query);
+ Assert.Equal("?code=evil&state=theirs", Assert.Single(rejected).Query);
+ }
+
+ [Fact]
+ public async Task Should_Not_Invoke_The_Response_Handler_For_A_Rejected_Callback()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+ var handlerCalls = 0;
+
+ var callbackUri = await SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc&state=mine"),
+ redirectUri,
+ s_timeout,
+ async _ =>
+ {
+ await GetAsync($"{redirectUri}?code=evil&state=theirs");
+ await GetAsync($"{redirectUri}?code=real&state=mine");
+ return true;
+ },
+ uri => HttpUtility.ParseQueryString(uri.Query)["state"] == "mine",
+ (_, _) =>
+ {
+ handlerCalls++;
+ return Task.CompletedTask;
+ },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal("?code=real&state=mine", callbackUri.Query);
+ Assert.Equal(1, handlerCalls);
+ }
+
+ [Fact]
+ public async Task Should_Report_Rejected_Callbacks_In_The_Timeout_Message()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+
+ var exception = await Assert.ThrowsAnyAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ redirectUri,
+ TimeSpan.FromSeconds(2),
+ async _ =>
+ {
+ await GetAsync($"{redirectUri}?code=one");
+ await GetAsync($"{redirectUri}?code=two");
+ return true;
+ },
+ _ => false,
+ null,
+ TestContext.Current.CancellationToken));
+
+ // A filter that never matches must not present as a silent wait.
+ Assert.Contains("2 callback(s) were rejected by the callback filter.", exception.Message);
+ }
+
+ [Fact]
+ public async Task Should_Surface_An_Exception_Thrown_By_The_Callback_Filter()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+ var expected = new InvalidOperationException("bad filter");
+
+ var exception = await Assert.ThrowsAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ redirectUri,
+ s_timeout,
+ async _ =>
+ {
+ // The listener faults before writing a response, so send raw rather than waiting on
+ // HttpClient for a reply that never comes.
+ await SendRawCallbackAsync(port, "/callback?code=abc");
+ return true;
+ },
+ _ => throw expected,
+ null,
+ TestContext.Current.CancellationToken));
+
+ Assert.Same(expected, exception);
+ }
+
+ [Theory]
+ [InlineData("https://127.0.0.1:5000/callback")]
+ [InlineData("http://example.com/callback")]
+ [InlineData("myapp://callback")]
+ public async Task Should_Reject_Non_Loopback_Http_Redirect_Uri(string redirectUri)
+ {
+ var exception = await Assert.ThrowsAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com"),
+ new Uri(redirectUri),
+ s_timeout,
+ _ => throw new InvalidOperationException("Browser should not be launched."),
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+
+ Assert.Equal("redirectUri", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task Should_Throw_When_The_Request_Uri_Has_No_Redirect_Uri_To_Coerce()
+ {
+ // The redirect uri has no explicit port, so the request's redirect_uri has to be rewritten
+ // with the dynamically allocated one - and there is nothing to rewrite here.
+ _ = await Assert.ThrowsAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ s_defaultRedirectUri,
+ s_timeout,
+ _ => throw new InvalidOperationException("Browser should not be launched."),
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Should_Throw_When_The_Browser_Cannot_Be_Launched()
+ {
+ var exception = await Assert.ThrowsAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ new Uri($"http://127.0.0.1:{GetFreePort()}/callback"),
+ s_timeout,
+ _ => Task.FromResult(false),
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+
+ Assert.Contains("system browser", exception.Message);
+ }
+
+ [Fact]
+ public async Task Should_Wrap_The_Exception_Thrown_By_The_Launcher()
+ {
+ var expected = new NotSupportedException("no browser here");
+
+ var exception = await Assert.ThrowsAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ new Uri($"http://127.0.0.1:{GetFreePort()}/callback"),
+ s_timeout,
+ _ => throw expected,
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+
+ Assert.Same(expected, exception.InnerException);
+ }
+
+ [Fact]
+ public async Task Should_Cancel_When_The_Callback_Times_Out()
+ {
+ // The user never completes the flow in the browser.
+ _ = await Assert.ThrowsAnyAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ new Uri($"http://127.0.0.1:{GetFreePort()}/callback"),
+ TimeSpan.FromMilliseconds(200),
+ _ => Task.FromResult(true),
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Should_Cancel_When_The_Caller_Cancels()
+ {
+ using var cts = new CancellationTokenSource();
+
+ var task = SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ new Uri($"http://127.0.0.1:{GetFreePort()}/callback"),
+ s_timeout,
+ _ =>
+ {
+ cts.Cancel();
+ return Task.FromResult(true);
+ },
+ null,
+ null,
+ cts.Token);
+
+ var exception = await Assert.ThrowsAnyAsync(() => task);
+
+ // Caller cancellation must not be reported as a timeout.
+ Assert.DoesNotContain("Timed out", exception.Message);
+ }
+
+ [Fact]
+ public async Task Should_Release_The_Port_When_The_Flow_Fails()
+ {
+ var port = GetFreePort();
+ var redirectUri = new Uri($"http://127.0.0.1:{port}/callback");
+
+ _ = await Assert.ThrowsAnyAsync(
+ () => SystemBrowserWebAuthenticationBroker.AuthenticateAsync(
+ new Uri("http://input.com/authorize?client_id=abc"),
+ redirectUri,
+ TimeSpan.FromMilliseconds(200),
+ _ => Task.FromResult(true),
+ null,
+ null,
+ TestContext.Current.CancellationToken));
+
+ // The listener must not keep the port bound after the flow is over.
+ using var listener = new LoopbackHttpListener(new Uri($"http://127.0.0.1:{port}/callback"));
+ Assert.Equal(port, listener.Port);
+ }
+
+ private static string RedirectUriOf(Uri requestUri) =>
+ HttpUtility.ParseQueryString(requestUri.Query)["redirect_uri"] ??
+ throw new InvalidOperationException("The launched uri has no redirect_uri.");
+
+ ///
+ /// Sends a callback request without expecting a well formed reply, and returns once the listener
+ /// closes the connection.
+ ///
+ private static async Task SendRawCallbackAsync(int port, string target)
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(IPAddress.Loopback, port);
+
+ var stream = client.GetStream();
+ await stream.WriteAsync(Encoding.ASCII.GetBytes($"GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"));
+ await stream.FlushAsync();
+
+ using var drain = new MemoryStream();
+ await stream.CopyToAsync(drain);
+ }
+
+ private static async Task GetAsync(string uri)
+ {
+ // Short timeout so a listener that stopped accepting fails the test quickly
+ // instead of waiting out HttpClient's 100 second default.
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ using var response = await http.GetAsync(uri);
+ return await response.Content.ReadAsStringAsync();
+ }
+
+ private static int GetFreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/tests/Avalonia.Controls.WebView.Tests/WebAuthenticationBrokerTests.cs b/tests/Avalonia.Controls.WebView.Tests/WebAuthenticationBrokerTests.cs
index 247a77c..3504916 100644
--- a/tests/Avalonia.Controls.WebView.Tests/WebAuthenticationBrokerTests.cs
+++ b/tests/Avalonia.Controls.WebView.Tests/WebAuthenticationBrokerTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Threading.Tasks;
using Avalonia.Headless.XUnit;
using Avalonia.Platform;
@@ -14,13 +14,69 @@ public async Task Should_Complete_Auth_Workflow()
var window = new Window();
window.Show();
- var inputUri = new Uri("http://input.com");
- var middleUri = new Uri("http://middle.com");
- var outputUri = new Uri("http://localhost");
- var extraArgs = new Uri("/?code=123", UriKind.Relative);
- var options = new WebAuthenticatorOptions(inputUri, outputUri)
+#pragma warning disable CA1416
+ var options = CreateDialogOptions() with { Mode = WebAuthenticatorMode.NativeWebDialog };
+#pragma warning restore CA1416
+
+ var result = await WebAuthenticationBroker.AuthenticateAsync(window, options);
+ Assert.Equal(ExpectedCallbackUri, result.CallbackUri);
+ }
+
+ // The completion path of WebAuthenticatorMode.Browser is covered by
+ // SystemBrowserWebAuthenticationBrokerTests: the broker takes its launcher from TopLevel.Launcher,
+ // which cannot be substituted, so only the paths that reject before launching are testable here.
+
+ [AvaloniaTheory(Timeout = 10_000)]
+ [InlineData("https://127.0.0.1:5000/callback")]
+ [InlineData("http://example.com/callback")]
+ [InlineData("myapp://callback")]
+ public async Task Should_Reject_Non_Loopback_Http_Redirect_Uri(string redirectUri)
+ {
+ var window = new Window();
+ window.Show();
+
+ var options = new WebAuthenticatorOptions(new Uri("http://input.com"), new Uri(redirectUri))
+ {
+ Mode = WebAuthenticatorMode.Browser,
+ BrowserOptions = new BrowserOptions
+ {
+ ResponseHandler = (_, _) => throw new InvalidOperationException("Callback should not be received.")
+ }
+ };
+
+ var exception = await Assert.ThrowsAsync(
+ () => WebAuthenticationBroker.AuthenticateAsync(window, options));
+ Assert.Equal("redirectUri", exception.ParamName);
+ }
+
+ [AvaloniaFact(Timeout = 30_000)]
+ public async Task Should_Throw_When_The_Browser_Cannot_Be_Launched()
+ {
+ var window = new Window();
+ window.Show();
+
+ // The headless platform exposes no ILauncher, so LaunchUriAsync reports failure.
+ var options = new WebAuthenticatorOptions(
+ new Uri("http://input.com"), new Uri($"http://127.0.0.1:{GetFreePort()}/callback"))
+ {
+ Mode = WebAuthenticatorMode.Browser,
+ BrowserOptions = new BrowserOptions { Timeout = TimeSpan.FromSeconds(5) }
+ };
+
+ _ = await Assert.ThrowsAsync(
+ () => WebAuthenticationBroker.AuthenticateAsync(window, options));
+ }
+
+ private static readonly Uri s_inputUri = new("http://input.com");
+ private static readonly Uri s_middleUri = new("http://middle.com");
+ private static readonly Uri s_outputUri = new("http://localhost");
+ private static readonly Uri s_extraArgs = new("/?code=123", UriKind.Relative);
+
+ private static Uri ExpectedCallbackUri => new(s_outputUri, s_extraArgs);
+
+ private static WebAuthenticatorOptions CreateDialogOptions() =>
+ new(s_inputUri, s_outputUri)
{
- PreferNativeWebDialog = true,
NativeWebDialogFactory = () =>
{
var dialog = new NativeWebDialog();
@@ -33,13 +89,13 @@ public async Task Should_Complete_Auth_Workflow()
headless.HttpHandler = async uri =>
{
await Task.Delay(10);
- if (uri == inputUri)
+ if (uri == s_inputUri)
return new HeadlessWebViewEnvironmentRequestedEventArgs.HttpResult(
- true, RedirectUri: middleUri);
- if (uri == middleUri)
+ true, RedirectUri: s_middleUri);
+ if (uri == s_middleUri)
return new HeadlessWebViewEnvironmentRequestedEventArgs.HttpResult(
- true, RedirectUri: new Uri(outputUri, extraArgs));
- if (uri.ToString().StartsWith(outputUri.ToString()))
+ true, RedirectUri: new Uri(s_outputUri, s_extraArgs));
+ if (uri.ToString().StartsWith(s_outputUri.ToString()))
Assert.Fail("Final localhost request should be canceled.");
return new HeadlessWebViewEnvironmentRequestedEventArgs.HttpResult(false);
};
@@ -49,7 +105,12 @@ public async Task Should_Complete_Auth_Workflow()
}
};
- var result = await WebAuthenticationBroker.AuthenticateAsync(window, options);
- Assert.Equal(new Uri(outputUri, extraArgs), result.CallbackUri);
+ private static int GetFreePort()
+ {
+ var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
}
}