From ce1d49b3a6df0ed115b826ae693f1197ed5d0c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Sat, 18 Feb 2023 11:15:12 +0100 Subject: [PATCH] Update the embedded web server to listen on 127.0.0.1/::1 when possible and use http://localhost/ as the default client URI --- .../Worker.cs | 6 +- .../InteractiveService.cs | 21 ++- .../OpenIddict.Sandbox.Console.Client.csproj | 2 +- .../Program.cs | 5 +- .../Program.cs | 15 ++- .../Worker.cs | 8 +- .../OpenIddict.Sandbox.Wpf.Client/Program.cs | 15 ++- .../OpenIddict.Sandbox.Wpf.Client/Worker.cs | 8 +- .../OpenIddictResources.resx | 4 +- ...penIddictClientSystemIntegrationBuilder.cs | 30 ++++- ...ictClientSystemIntegrationConfiguration.cs | 21 +-- ...enIddictClientSystemIntegrationHandlers.cs | 91 ++++++++++++- ...penIddictClientSystemIntegrationHelpers.cs | 44 ++++--- ...dictClientSystemIntegrationHttpListener.cs | 123 ++++++++++++++---- ...penIddictClientSystemIntegrationOptions.cs | 11 ++ 15 files changed, 317 insertions(+), 87 deletions(-) diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index 21eebe48..3d439d71 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -1,6 +1,6 @@ using System.Globalization; -using OpenIddict.Sandbox.AspNetCore.Server.Models; using OpenIddict.Abstractions; +using OpenIddict.Sandbox.AspNetCore.Server.Models; using static OpenIddict.Abstractions.OpenIddictConstants; namespace OpenIddict.Sandbox.AspNetCore.Server; @@ -123,7 +123,7 @@ public class Worker : IHostedService }, RedirectUris = { - new Uri("openiddict-sandbox-winforms-client://localhost/callback/login/local") + new Uri("com.openiddict.sandbox.winforms.client:/callback/login/local") }, Permissions = { @@ -157,7 +157,7 @@ public class Worker : IHostedService }, RedirectUris = { - new Uri("openiddict-sandbox-wpf-client://localhost/callback/login/local") + new Uri("com.openiddict.sandbox.wpf.client:/callback/login/local") }, Permissions = { diff --git a/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs b/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs index e89b2af5..7ce7e43f 100644 --- a/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs +++ b/sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs @@ -23,8 +23,8 @@ public class InteractiveService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Wait for the host to confirm that the application has started. - var source = new TaskCompletionSource(); - using (_lifetime.ApplicationStarted.Register(static state => ((TaskCompletionSource) state!).SetResult(), source)) + var source = new TaskCompletionSource(); + using (_lifetime.ApplicationStarted.Register(static state => ((TaskCompletionSource) state!).SetResult(true), source)) { await source.Task; } @@ -37,7 +37,7 @@ public class InteractiveService : BackgroundService { Console.WriteLine("Type '1' + ENTER to log in using the local server or '2' + ENTER to log in using Twitter"); - provider = await Task.Run(Console.ReadLine).WaitAsync(stoppingToken) switch + provider = await WaitAsync(Task.Run(Console.ReadLine, stoppingToken), stoppingToken) switch { "1" => "Local", "2" => "Twitter", @@ -74,5 +74,20 @@ public class InteractiveService : BackgroundService Console.WriteLine("An error occurred while trying to authenticate the user."); } } + + static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + var source = new TaskCompletionSource(TaskCreationOptions.None); + + using (cancellationToken.Register(static state => ((TaskCompletionSource) state!).SetResult(true), source)) + { + if (await Task.WhenAny(task, source.Task) == source.Task) + { + throw new OperationCanceledException(cancellationToken); + } + + return await task; + } + } } } diff --git a/sandbox/OpenIddict.Sandbox.Console.Client/OpenIddict.Sandbox.Console.Client.csproj b/sandbox/OpenIddict.Sandbox.Console.Client/OpenIddict.Sandbox.Console.Client.csproj index fadc4aff..59f62c25 100644 --- a/sandbox/OpenIddict.Sandbox.Console.Client/OpenIddict.Sandbox.Console.Client.csproj +++ b/sandbox/OpenIddict.Sandbox.Console.Client/OpenIddict.Sandbox.Console.Client.csproj @@ -2,7 +2,7 @@ Exe - net7.0 + net48;net7.0 true false false diff --git a/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs b/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs index 594685b4..f8e66753 100644 --- a/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs +++ b/sandbox/OpenIddict.Sandbox.Console.Client/Program.cs @@ -54,9 +54,6 @@ var host = new HostBuilder() .EnableEmbeddedWebServer() .UseSystemBrowser(); - // Set the client URI that will uniquely identify this application. - options.SetClientUri(new Uri("http://localhost/", UriKind.Absolute)); - // Register the System.Net.Http integration and use the identity of the current // assembly as a more specific user agent, which can be useful when dealing with // providers that use the user agent as a way to throttle requests (e.g Reddit). @@ -84,7 +81,7 @@ var host = new HostBuilder() .UseTwitter() .SetClientId("bXgwc0U3N3A3YWNuaWVsdlRmRWE6MTpjaQ") .SetClientSecret("VcohOgBp-6yQCurngo4GAyKeZh0D6SUCCSjJgEo1uRzJarjIUS") - .SetRedirectUri(new Uri("callback/login/twitter", UriKind.Relative)); + .SetRedirectUri("callback/login/twitter"); }); // Register the worker responsible for creating the database used to store tokens diff --git a/sandbox/OpenIddict.Sandbox.WinForms.Client/Program.cs b/sandbox/OpenIddict.Sandbox.WinForms.Client/Program.cs index fff24bd6..f6e7c2ab 100644 --- a/sandbox/OpenIddict.Sandbox.WinForms.Client/Program.cs +++ b/sandbox/OpenIddict.Sandbox.WinForms.Client/Program.cs @@ -50,9 +50,6 @@ var host = new HostBuilder() // Add the operating system integration. options.UseSystemIntegration(); - // Set the client URI that will uniquely identify this application. - options.SetClientUri(new Uri("openiddict-sandbox-winforms-client://localhost/", UriKind.Absolute)); - // Register the System.Net.Http integration and use the identity of the current // assembly as a more specific user agent, which can be useful when dealing with // providers that use the user agent as a way to throttle requests (e.g Reddit). @@ -66,7 +63,14 @@ var host = new HostBuilder() ProviderName = "Local", ClientId = "winforms", - RedirectUri = new Uri("callback/login/local", UriKind.Relative), + + // This sample uses protocol activations with a custom URI scheme to handle callbacks. + // + // For more information on how to construct private-use URI schemes, + // read https://www.rfc-editor.org/rfc/rfc8252#section-7.1 and + // https://www.rfc-editor.org/rfc/rfc7595#section-3.8. + RedirectUri = new Uri("com.openiddict.sandbox.winforms.client:/callback/login/local", UriKind.Absolute), + Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" } }); @@ -80,7 +84,8 @@ var host = new HostBuilder() .UseTwitter() .SetClientId("bXgwc0U3N3A3YWNuaWVsdlRmRWE6MTpjaQ") .SetClientSecret("VcohOgBp-6yQCurngo4GAyKeZh0D6SUCCSjJgEo1uRzJarjIUS") - .SetRedirectUri(new Uri("callback/login/twitter", UriKind.Relative)); + // Note: Twitter doesn't support the recommended ":/" syntax and requires using "://". + .SetRedirectUri("com.openiddict.sandbox.winforms.client://callback/login/twitter"); }); // Register the worker responsible for creating the database used to store tokens diff --git a/sandbox/OpenIddict.Sandbox.WinForms.Client/Worker.cs b/sandbox/OpenIddict.Sandbox.WinForms.Client/Worker.cs index d9fb4999..a1fd76cc 100644 --- a/sandbox/OpenIddict.Sandbox.WinForms.Client/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.WinForms.Client/Worker.cs @@ -23,18 +23,20 @@ public class Worker : IHostedService RegistryKey? root = null; // Create the registry entries necessary to handle URI protocol activations. + // // Note: the application MUST be run once as an administrator for this to work, // so this should typically be done by a dedicated installer or a setup script. + // // Alternatively, the application can be packaged and use windows.protocol to // register the protocol handler/custom URI scheme with the operation system. try { - root = Registry.ClassesRoot.OpenSubKey("openiddict-sandbox-winforms-client"); + root = Registry.ClassesRoot.OpenSubKey("com.openiddict.sandbox.winforms.client"); if (root is null) { - root = Registry.ClassesRoot.CreateSubKey("openiddict-sandbox-winforms-client"); - root.SetValue(string.Empty, "URL:openiddict-sandbox-winforms-client"); + root = Registry.ClassesRoot.CreateSubKey("com.openiddict.sandbox.winforms.client"); + root.SetValue(string.Empty, "URL:com.openiddict.sandbox.winforms.client"); root.SetValue("URL Protocol", string.Empty); using var command = root.CreateSubKey("shell\\open\\command"); diff --git a/sandbox/OpenIddict.Sandbox.Wpf.Client/Program.cs b/sandbox/OpenIddict.Sandbox.Wpf.Client/Program.cs index b3ec45e4..befe06be 100644 --- a/sandbox/OpenIddict.Sandbox.Wpf.Client/Program.cs +++ b/sandbox/OpenIddict.Sandbox.Wpf.Client/Program.cs @@ -51,9 +51,6 @@ var host = new HostBuilder() // Add the operating system integration. options.UseSystemIntegration(); - // Set the client URI that will uniquely identify this application. - options.SetClientUri(new Uri("openiddict-sandbox-wpf-client://localhost/", UriKind.Absolute)); - // Register the System.Net.Http integration and use the identity of the current // assembly as a more specific user agent, which can be useful when dealing with // providers that use the user agent as a way to throttle requests (e.g Reddit). @@ -67,7 +64,14 @@ var host = new HostBuilder() ProviderName = "Local", ClientId = "wpf", - RedirectUri = new Uri("callback/login/local", UriKind.Relative), + + // This sample uses protocol activations with a custom URI scheme to handle callbacks. + // + // For more information on how to construct private-use URI schemes, + // read https://www.rfc-editor.org/rfc/rfc8252#section-7.1 and + // https://www.rfc-editor.org/rfc/rfc7595#section-3.8. + RedirectUri = new Uri("com.openiddict.sandbox.wpf.client:/callback/login/local", UriKind.Absolute), + Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" } }); @@ -81,7 +85,8 @@ var host = new HostBuilder() .UseTwitter() .SetClientId("bXgwc0U3N3A3YWNuaWVsdlRmRWE6MTpjaQ") .SetClientSecret("VcohOgBp-6yQCurngo4GAyKeZh0D6SUCCSjJgEo1uRzJarjIUS") - .SetRedirectUri(new Uri("callback/login/twitter", UriKind.Relative)); + // Note: Twitter doesn't support the recommended ":/" syntax and requires using "://". + .SetRedirectUri("com.openiddict.sandbox.wpf.client://callback/login/twitter"); }); // Register the worker responsible for creating the database used to store tokens diff --git a/sandbox/OpenIddict.Sandbox.Wpf.Client/Worker.cs b/sandbox/OpenIddict.Sandbox.Wpf.Client/Worker.cs index d6723830..17ca7d96 100644 --- a/sandbox/OpenIddict.Sandbox.Wpf.Client/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.Wpf.Client/Worker.cs @@ -23,18 +23,20 @@ public class Worker : IHostedService RegistryKey? root = null; // Create the registry entries necessary to handle URI protocol activations. + // // Note: the application MUST be run once as an administrator for this to work, // so this should typically be done by a dedicated installer or a setup script. + // // Alternatively, the application can be packaged and use windows.protocol to // register the protocol handler/custom URI scheme with the operation system. try { - root = Registry.ClassesRoot.OpenSubKey("openiddict-sandbox-wpf-client"); + root = Registry.ClassesRoot.OpenSubKey("com.openiddict.sandbox.wpf.client"); if (root is null) { - root = Registry.ClassesRoot.CreateSubKey("openiddict-sandbox-wpf-client"); - root.SetValue(string.Empty, "URL:openiddict-sandbox-wpf-client"); + root = Registry.ClassesRoot.CreateSubKey("com.openiddict.sandbox.wpf.client"); + root.SetValue(string.Empty, "URL:com.openiddict.sandbox.wpf.client"); root.SetValue("URL Protocol", string.Empty); using var command = root.CreateSubKey("shell\\open\\command"); diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 0df308ed..9ead9617 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1451,7 +1451,7 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId An error occurred while waiting for the authentication operation to complete, which may indicate a nonce collision. Make sure nonces are unique, contain enough entropy and are generated using a crypto-secure random number generator. - An explicit client URI must be set when using the OpenIddict client system integration. To set the client URI, use 'services.AddOpenIddict().AddClient().SetClientUri()'. + An error occurred while trying to create an embedded web server on port {0}. The default system browser couldn't be started. If the application executes inside a sandbox, make sure it is allowed to launch URIs or spawn new processes. @@ -1472,7 +1472,7 @@ To apply post-logout redirection responses, create a class implementing 'IOpenId The HTTP listener context cannot be resolved or contains invalid data. - An error occurred while instantiating the embedded web server, which may indicate a permission issue preventing the ports in the IANA dynamic ports range from being allocated. + An error occurred while instantiating the embedded web server, which may indicate a permission issue. The web authentication broker is not supported on this platform. diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationBuilder.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationBuilder.cs index 9c66be39..be183899 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationBuilder.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationBuilder.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.IO.Pipes; +using System.Net; using System.Runtime.InteropServices; using System.Runtime.Versioning; using OpenIddict.Client.SystemIntegration; @@ -65,7 +66,6 @@ public sealed class OpenIddictClientSystemIntegrationBuilder throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0392)); } -#if SUPPORTS_WINDOWS_RUNTIME if (!OpenIddictClientSystemIntegrationHelpers.IsWindowsRuntimeSupported()) { throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0392)); @@ -73,9 +73,6 @@ public sealed class OpenIddictClientSystemIntegrationBuilder return Configure(options => options.AuthenticationMode = OpenIddictClientSystemIntegrationAuthenticationMode.WebAuthenticationBroker); -#else - throw new PlatformNotSupportedException(SR.GetResourceString(SR.ID0392)); -#endif } /// @@ -86,6 +83,31 @@ public sealed class OpenIddictClientSystemIntegrationBuilder => Configure(options => options.AuthenticationMode = OpenIddictClientSystemIntegrationAuthenticationMode.SystemBrowser); + /// + /// Sets the list of static ports the embedded web server will be allowed to + /// listen on, if enabled. The first port in the list that is not already used + /// by another program is automatically chosen and the other ports are ignored. + /// + /// + /// If this property is not explicitly set, a port in the 49152-65535 + /// dynamic ports range is automatically chosen by OpenIddict at runtime. + /// + /// The static ports the embedded web server will be allowed to listen on. + /// The . + public OpenIddictClientSystemIntegrationBuilder SetAllowedEmbeddedWebServerPorts(params int[] ports) + { + if (Array.Exists(ports, static port => port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)) + { + throw new ArgumentOutOfRangeException(nameof(ports)); + } + + return Configure(options => + { + options.AllowedEmbeddedWebServerPorts.Clear(); + options.AllowedEmbeddedWebServerPorts.AddRange(ports); + }); + } + /// /// Sets the timeout after which authentication demands that /// are not completed are automatically aborted by OpenIddict. diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs index b6e60a79..82cc11fc 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationConfiguration.cs @@ -61,11 +61,16 @@ public sealed class OpenIddictClientSystemIntegrationConfiguration : IConfigureO throw new ArgumentNullException(nameof(options)); } - // Ensure an explicit client URI was set when using the system integration. - if (options.ClientUri is not { IsAbsoluteUri: true }) - { - throw new InvalidOperationException(SR.GetResourceString(SR.ID0384)); - } + // If no explicit client URI was set, default to the static "http://localhost/" address, which is + // adequate for a native/mobile client and points to the embedded web server when it is enabled. + // + // Note: while the RFC8252 specification recommends using 127.0.0.1 or ::1 instead of "localhost", + // OpenIddict deliberately uses "localhost" to be compatible with platforms that don't allow binding + // on loopback addresses without administrator rights and to avoid using a hard-to-predict client URI + // that would be tied to a specific IP version dependent on the protocols supported/allowed by the OS. + // + // See https://www.rfc-editor.org/rfc/rfc8252#section-8.3 for more information. + options.ClientUri ??= new Uri("http://localhost/", UriKind.Absolute); } /// @@ -161,12 +166,8 @@ public sealed class OpenIddictClientSystemIntegrationConfiguration : IConfigureO [MethodImpl(MethodImplOptions.NoInlining)] [SupportedOSPlatform("windows")] static bool IsRunningInAppContainer(WindowsIdentity identity) -#if SUPPORTS_WINDOWS_RUNTIME - => OpenIddictClientSystemIntegrationHelpers.IsWindowsRuntimeSupported() && + => OpenIddictClientSystemIntegrationHelpers.IsWindowsVersionAtLeast(10, 0, 10240) && OpenIddictClientSystemIntegrationHelpers.HasAppContainerToken(identity); -#else - => false; -#endif } } } diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs index 8ac93934..c7ce6f8e 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHandlers.cs @@ -37,6 +37,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers ResolveRequestUriFromHttpListenerRequest.Descriptor, ResolveRequestUriFromProtocolActivation.Descriptor, ResolveRequestUriFromWebAuthenticationResult.Descriptor, + InferEndpointTypeFromDynamicAddress.Descriptor, RejectUnknownHttpRequests.Descriptor, /* @@ -133,7 +134,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers // that don't include a Host header (e.g HTTP/1.0 requests) or specify an invalid value. { Request.Url: { IsAbsoluteUri: true } uri } => ( - BaseUri: new Uri(uri.GetLeftPart(UriPartial.Authority), UriKind.Absolute), + BaseUri: new UriBuilder(uri) { Path = null, Query = null, Fragment = null }.Uri, RequestUri: uri), _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0390)) @@ -171,7 +172,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers (context.BaseUri, context.RequestUri) = context.Transaction.GetProtocolActivation() switch { { ActivationUri: Uri uri } => ( - BaseUri: new Uri(uri.GetLeftPart(UriPartial.Authority), UriKind.Absolute), + BaseUri: new UriBuilder(uri) { Path = null, Query = null, Fragment = null }.Uri, RequestUri: uri), _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0375)) @@ -211,7 +212,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers { { ResponseStatus: WebAuthenticationStatus.Success, ResponseData: string data } when Uri.TryCreate(data, UriKind.Absolute, out Uri? uri) => ( - BaseUri: new Uri(uri.GetLeftPart(UriPartial.Authority), UriKind.Absolute), + BaseUri: new UriBuilder(uri) { Path = null, Query = null, Fragment = null }.Uri, RequestUri: uri), _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0393)) @@ -224,6 +225,84 @@ public static partial class OpenIddictClientSystemIntegrationHandlers } } + /// + /// Contains the logic responsible for inferring the endpoint type from the request URI, ignoring + /// the port when comparing the request URI with the endpoint URIs configured in the options. + /// Note: this handler is not used when the OpenID Connect request is not handled by the embedded web server. + /// + public sealed class InferEndpointTypeFromDynamicAddress : IOpenIddictClientHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictClientHandlerDescriptor Descriptor { get; } + = OpenIddictClientHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(InferEndpointType.Descriptor.Order + 250) + .SetType(OpenIddictClientHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (context is not { BaseUri.IsAbsoluteUri: true, RequestUri.IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0127)); + } + + // If an endpoint was already inferred by the generic handler, don't override it. + if (context.EndpointType is not OpenIddictClientEndpointType.Unknown) + { + return default; + } + + context.EndpointType = + Matches(context.Options.RedirectionEndpointUris) ? OpenIddictClientEndpointType.Redirection : + Matches(context.Options.PostLogoutRedirectionEndpointUris) ? OpenIddictClientEndpointType.PostLogoutRedirection : + OpenIddictClientEndpointType.Unknown; + + return default; + + bool Matches(IReadOnlyList uris) + { + for (var index = 0; index < uris.Count; index++) + { + var uri = uris[index]; + if (uri.IsAbsoluteUri && uri.IsLoopback && uri.IsDefaultPort && Equals(uri, context.RequestUri)) + { + return true; + } + } + + return false; + } + + static bool Equals(Uri left, Uri right) => + string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) && + // + // Deliberately ignore the port when doing comparisons in this specialized handler. + // + // Note: paths are considered equivalent even if the casing isn't identical or if one of the two + // paths only differs by a trailing slash, which matches the classical behavior seen on ASP.NET, + // Microsoft.Owin/Katana and ASP.NET Core. Developers who prefer a different behavior can remove + // this handler and replace it by a custom version implementing a more strict comparison logic. + (string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.OrdinalIgnoreCase) || + (left.AbsolutePath.Length == right.AbsolutePath.Length + 1 && + left.AbsolutePath.StartsWith(right.AbsolutePath, StringComparison.OrdinalIgnoreCase) && + left.AbsolutePath[^1] is '/') || + (right.AbsolutePath.Length == left.AbsolutePath.Length + 1 && + right.AbsolutePath.StartsWith(left.AbsolutePath, StringComparison.OrdinalIgnoreCase) && + right.AbsolutePath[^1] is '/')); + } + } + /// /// Contains the logic responsible for rejecting unknown requests handled by the embedded web server, if applicable. /// Note: this handler is not used when the OpenID Connect request is not handled by the embedded web server. @@ -237,7 +316,7 @@ public static partial class OpenIddictClientSystemIntegrationHandlers = OpenIddictClientHandlerDescriptor.CreateBuilder() .AddFilter() .UseSingletonHandler() - .SetOrder(InferEndpointType.Descriptor.Order + 500) + .SetOrder(InferEndpointTypeFromDynamicAddress.Descriptor.Order + 250) .SetType(OpenIddictClientHandlerType.BuiltIn) .Build(); @@ -1417,13 +1496,13 @@ public static partial class OpenIddictClientSystemIntegrationHandlers throw new ArgumentNullException(nameof(context)); } - // If the redirect_uri uses "localhost" as the authority and doesn't include a non-default port, + // If the redirect_uri uses a loopback host/IP as the authority and doesn't include a non-default port, // determine whether the embedded web server is running: if so, override the port in the redirect_uri // by the port used by the embedded web server (guaranteed to be running if a value is returned). if (!string.IsNullOrEmpty(context.RedirectUri) && Uri.TryCreate(context.RedirectUri, UriKind.Absolute, out Uri? uri) && string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && - string.Equals(uri.Authority, "localhost", StringComparison.OrdinalIgnoreCase) && uri.IsDefaultPort && + uri.IsLoopback && uri.IsDefaultPort && await _listener.GetEmbeddedServerPortAsync(context.CancellationToken) is int port) { var builder = new UriBuilder(context.RedirectUri) diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHelpers.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHelpers.cs index 61d828d1..ccd7d799 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHelpers.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHelpers.cs @@ -51,28 +51,25 @@ public static class OpenIddictClientSystemIntegrationHelpers /// The instance or if it couldn't be found. public static WebAuthenticationResult? GetWebAuthenticationResult(this OpenIddictClientTransaction transaction) => transaction.GetProperty(typeof(WebAuthenticationResult).FullName!); +#endif /// - /// Determines whether the Windows Runtime APIs are supported on this platform. + /// Determines whether the current Windows version + /// is greater than or equals to the specified version. /// - /// if the Windows Runtime APIs are supported, otherwise. + /// + /// if the current Windows version is greater than + /// or equals to the specified version, otherwise. + /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SupportedOSPlatform("windows")] - [SupportedOSPlatformGuard("windows10.0.17763")] - internal static bool IsWindowsRuntimeSupported() + [SupportedOSPlatformGuard("windows")] + internal static bool IsWindowsVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0) { - // Note: as WinRT is only supported on Windows 8 and higher, trying to call any of the - // WinRT APIs on previous versions of Windows will typically result in type-load or - // type-initialization exceptions. To prevent that, this method acts as a platform - // guard that will prevent the WinRT projections from being loaded by the runtime on - // platforms that don't support it. Since OpenIddict declares Windows 10 1809 as the - // oldest supported version in the package, it is also used for the runtime check. - #if SUPPORTS_OPERATING_SYSTEM_VERSIONS_COMPARISON - return OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763); + return OperatingSystem.IsWindowsVersionAtLeast(major, minor, build, revision); #else if (Environment.OSVersion.Platform is PlatformID.Win32NT && - Environment.OSVersion.Version >= new Version(10, 0, 17763)) + Environment.OSVersion.Version >= new Version(major, minor, build, revision)) { return true; } @@ -84,10 +81,24 @@ public static class OpenIddictClientSystemIntegrationHelpers // the hood) is made. Note: no version is returned on UWP due to the missing Win32 API. return RuntimeInformation.OSDescription.StartsWith("Microsoft Windows ", StringComparison.OrdinalIgnoreCase) && RuntimeInformation.OSDescription["Microsoft Windows ".Length..] is string value && - Version.TryParse(value, out Version? version) && version >= new Version(10, 0, 17763); + Version.TryParse(value, out Version? version) && version >= new Version(major, minor, build, revision); #endif } + /// + /// Determines whether the Windows Runtime APIs are supported on this platform. + /// + /// if the Windows Runtime APIs are supported, otherwise. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [SupportedOSPlatformGuard("windows10.0.17763")] + // Note: as WinRT is only supported on Windows 8 and higher, trying to call any of the + // WinRT APIs on previous versions of Windows will typically result in type-load or + // type-initialization exceptions. To prevent that, this method acts as a platform + // guard that will prevent the WinRT projections from being loaded by the runtime on + // platforms that don't support it. Since OpenIddict declares Windows 10 1809 as the + // oldest supported version in the package, it is also used for the runtime check. + internal static bool IsWindowsRuntimeSupported() => IsWindowsVersionAtLeast(10, 0, 17763); + /// /// Determines whether the specified identity contains an AppContainer /// token, indicating it's running in an AppContainer sandbox. @@ -97,7 +108,7 @@ public static class OpenIddictClientSystemIntegrationHelpers /// if the specified identity contains an /// AppContainer token, otherwise. /// - [SupportedOSPlatform("windows10.0.17763")] + [SupportedOSPlatform("windows10.0.10240")] internal static unsafe bool HasAppContainerToken(WindowsIdentity identity) { if (identity is null) @@ -129,6 +140,7 @@ public static class OpenIddictClientSystemIntegrationHelpers out uint ReturnLength); } +#if SUPPORTS_WINDOWS_RUNTIME /// /// Resolves the protocol activation using the Windows Runtime APIs, if applicable. /// diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHttpListener.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHttpListener.cs index 947bdbed..7ae60ff5 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHttpListener.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationHttpListener.cs @@ -7,6 +7,8 @@ using System.ComponentModel; using System.Globalization; using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -44,6 +46,14 @@ public sealed class OpenIddictClientSystemIntegrationHttpListener : BackgroundSe /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Note: the RFC8252 specification recommends starting the web server only when an authorization request is + // about to be sent and closing it when the response is received. Unfortunately, such an approach has important + // downsides, as it increases the delay seen by the user before the browser is launched and differs potential + // server initialization errors. To avoid degrading the user experience, the embedded web server is started in + // parallel to the host and unsollicted callback requests are always rejected (as they don't include a valid + // state token). Whenever possible, the HTTP listener is configured to only listen on loopback IP endpoints + // and rejects unknown requests with an HTTP 404, making attacks targeting the embedded web server unlikely. + // If the embedded web server instantiation was not enabled, signal the task completion source with a // null value to inform the handlers that no HTTP listener is going to be created and return immediately. if (_options.CurrentValue.EnableEmbeddedWebServer is not true) @@ -58,14 +68,14 @@ public sealed class OpenIddictClientSystemIntegrationHttpListener : BackgroundSe // To ensure the host initialization is not blocked, the whole process is offloaded to the thread pool. await Task.Run(cancellationToken: stoppingToken, function: async () => { - var (listener, port) = CreateHttpListener(stoppingToken) ?? - throw new InvalidOperationException(SR.GetResourceString(SR.ID0391)); - - // Inform the handlers that the HTTP listener was created and can now be accessed via the specified port. - _source.SetResult(port); - + var (listener, port) = CreateHttpListener(_options.CurrentValue.AllowedEmbeddedWebServerPorts, stoppingToken); using (listener) { + // Inform the handlers that the HTTP listener was created and can + // now be accessed via the static port configured in the options + // or dynamically chosen at runtime in the IANA dynamic ports range. + _source.SetResult(port); + // Note: while the received load should be minimal, 3 task workers are used // to be able to process multiple requests at the same time, if necessary. var tasks = new Task[3]; @@ -87,32 +97,89 @@ public sealed class OpenIddictClientSystemIntegrationHttpListener : BackgroundSe return; } - static (HttpListener Listener, int Port)? CreateHttpListener(CancellationToken cancellationToken) + static (HttpListener Listener, int Port) CreateHttpListener(List ports, CancellationToken cancellationToken) { - // Note: HttpListener doesn't offer a native way to select a random, non-busy port. - // To work around this limitation, this local function tries to bind an HttpListener - // on the first free port in the IANA dynamic ports range (typically: 49152 to 65535). + // Note: HttpListener doesn't offer a native way to select a non-busy port from + // an arbitrary list. To work around this limitation, this local function tries + // to bind an HttpListener on the first free port in the specified list or in + // the IANA dynamic ports range if the list doesn't contain any explicit port. // // For more information, see // https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml. - for (var port = 49152; port < 65535; port++) + Stack? exceptions = null; + + for (var port = IPEndPoint.MinPort; port <= IPEndPoint.MaxPort; port++) { cancellationToken.ThrowIfCancellationRequested(); + // If one or more explicit ports were specified, ignore ports that are not listed. + // Otherwise, ignore all the ports outside the IANA dynamic ports range. + if (ports.Count is 0) + { + if (port < 49152) + { + continue; + } + } + + else if (!ports.Contains(port)) + { + continue; + } + var listener = new HttpListener { AuthenticationSchemes = AuthenticationSchemes.Anonymous, - IgnoreWriteExceptions = true, - - // Note: the prefix registration is deliberately not configurable to ensure - // only the "localhost" authority is used, which enforces the built-in host - // validation performed by HTTP.sys (or the managed .NET implementation on - // non-Windows operating systems) and doesn't require running the application - // as an administrator or adding a namespace reservation/ACL rule on Windows. - Prefixes = { $"http://localhost:{port.ToString(CultureInfo.InvariantCulture)}/" } + IgnoreWriteExceptions = true }; + // Note: the prefix registration is deliberately not configurable to ensure + // only loopback authorities are used, which enforces the built-in host header + // validation performed by HTTP.sys (or the managed .NET implementation on + // non-Windows operating systems) and doesn't require running the application + // as an administrator or adding a namespace reservation/ACL rule on Windows. + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On Windows 10 1511 and higher, listening on 127.0.0.1 and ::1 is preferred + // to localhost as it allows ignoring requests that are sent by other machines + // located on the same network (even if the firewall is not enabled or not + // configured to reject such requests) without requiring administrator rights. + // + // See https://www.rfc-editor.org/rfc/rfc8252#section-8.3 for more information. + if (OpenIddictClientSystemIntegrationHelpers.IsWindowsVersionAtLeast(10, 0, 10586)) + { + if (Socket.OSSupportsIPv4) + { + listener.Prefixes.Add($"http://{IPAddress.Loopback}:{port.ToString(CultureInfo.InvariantCulture)}/"); + } + + if (Socket.OSSupportsIPv6) + { + listener.Prefixes.Add($"http://[{IPAddress.IPv6Loopback}]:{port.ToString(CultureInfo.InvariantCulture)}/"); + } + } + + // On older versions, listening on 127.0.0.1 and ::1 requires administrator rights. + else + { + listener.Prefixes.Add($"http://localhost:{port.ToString(CultureInfo.InvariantCulture)}/"); + } + } + + else + { + // Note: the managed HttpListener implementation doesn't support IPv6 and + // doesn't allow sending a Host header containing the "localhost" authority + // when binding on the 127.0.0.1 address. To keep using "localhost" instead of + // being forced to use 127.0.0.1, the embedded web server is configured to listen + // on "localhost" on platforms that use the managed HttpListener implementation. + // + // See https://github.com/dotnet/runtime/issues/34399 for more information. + listener.Prefixes.Add($"http://localhost:{port.ToString(CultureInfo.InvariantCulture)}/"); + } + try { listener.Start(); @@ -120,19 +187,31 @@ public sealed class OpenIddictClientSystemIntegrationHttpListener : BackgroundSe return (listener, port); } - catch (HttpListenerException) + catch (HttpListenerException exception) { listener.Close(); + + exceptions ??= new(capacity: 3); + exceptions.Push(new InvalidOperationException(SR.FormatID0384(port), exception)); + } + + catch (Exception exception) + { + listener.Close(); + + throw new InvalidOperationException(SR.GetResourceString(SR.ID0391), exception); } } - return null; + throw exceptions is { Count: > 0 } ? + new InvalidOperationException(SR.GetResourceString(SR.ID0391), new AggregateException(exceptions.Take(3))) : + new InvalidOperationException(SR.GetResourceString(SR.ID0391)); } static async Task ProcessRequestsAsync(HttpListener listener, OpenIddictClientSystemIntegrationService service, ILogger logger, CancellationToken cancellationToken) { - while (true) + while (listener.IsListening) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationOptions.cs b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationOptions.cs index a2dd36ab..22b7094a 100644 --- a/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationOptions.cs +++ b/src/OpenIddict.Client.SystemIntegration/OpenIddictClientSystemIntegrationOptions.cs @@ -28,6 +28,17 @@ public sealed class OpenIddictClientSystemIntegrationOptions /// public TimeSpan AuthenticationTimeout { get; set; } = TimeSpan.FromMinutes(10); + /// + /// Gets the list of static ports the embedded web server will be allowed to + /// listen on, if enabled. The first port in the list that is not already used + /// by another program is automatically chosen and the other ports are ignored. + /// + /// + /// If this property is not explicitly set, a port in the 49152-65535 + /// dynamic ports range is automatically chosen by OpenIddict at runtime. + /// + public List AllowedEmbeddedWebServerPorts { get; } = new(); + /// /// Gets or sets a boolean indicating whether protocol activation processing should be enabled. ///