diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 648ea8f..095c83e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,6 +26,11 @@ jobs: uses: actions/setup-dotnet@v3 with: dotnet-version: '9.0' + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '10.0' - name: Build run: dotnet build Yllibed.HttpServer.slnx /p:Configuration=Release diff --git a/Directory.Build.props b/Directory.Build.props index b74a2ec..0791b09 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -53,5 +53,16 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 19923cb..6e95b23 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,18 +1,24 @@ - + - - - - + + + + - - + + - - + + + - \ No newline at end of file + + + + + + diff --git a/README.md b/README.md index 681a4cb..9bcdfcf 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,8 @@ If you need to expose it on a public or untrusted network: - Alternatively, use a secure tunnel (SSH, Cloudflare Tunnel, etc.). - Bind to loopback only (127.0.0.1 / ::1) when you want to ensure local-only access. -Note: Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed. +> [!NOTE] +> Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed. ### GuardHandler (basic request filtering) GuardHandler provides best-effort filtering of incoming requests, rejecting obviously problematic or oversized requests early. This is lightweight filtering against unsophisticated attacks, not comprehensive security. @@ -240,6 +241,9 @@ services.AddYllibedHttpServer(opts => - Load balancer configurations requiring static endpoints - Development scenarios where you need predictable URLs +> [!IMPORTANT] +> Remark: Only one `Server` instance (per address family) can bind to a given fixed port. Attempting to start a second server or test on the same port (e.g., two OAuth flows both forcing port 5001) will throw a `System.Net.Sockets.SocketException` (address already in use). Use dynamic ports (`Port = 0`) unless an OAuth provider mandates an exact fixed redirect URI. + ### Basic Configuration Example ```csharp @@ -450,3 +454,71 @@ Notes: - Caching: Cache-Control: no-cache is added by default for SSE responses unless you override it via headers. - Retry: The SSE spec allows the server to send a retry: field to suggest a reconnection delay. This helper does not currently provide a dedicated API for retry frames. Most clients also implement their own backoff. If you need this, you can write raw lines through a custom handler or open an issue. - CORS: If you need cross-origin access, add appropriate headers (e.g., Access-Control-Allow-Origin) via the headers parameter when starting the SSE session. + +### OAuthCallbackHandler Usage + +The `OAuthCallbackHandler` helps capture an OAuth2.0 (or similar) redirect to a localhost HTTP endpoint and provides a `WaitForCallbackAsync()` method to retrieve the result once the browser hits the callback URL. + +Minimal manual registration (fixed port example): +```csharp +var server = new Server(5001); // Fixed port must match the registered redirect URI with the provider +var callbackHandler = new OAuthCallbackHandler(new Uri("http://localhost:5001/oauth/callback")); +server.RegisterHandler(callbackHandler); +var (uri4, _) = server.Start(); +Console.WriteLine($"Listening for OAuth redirect at: {callbackHandler.CallbackUri}"); + +// Later, wait for the incoming redirect +var result = await callbackHandler.WaitForCallbackAsync(); +Console.WriteLine($"Status: {result.ResponseStatus}, Raw URI: {result.ResponseData}"); +``` + +Using Microsoft DI with automatic handler registration: +```csharp +var services = new ServiceCollection(); +services.AddYllibedHttpServer(opts => +{ + opts.Port = 5001; // Fixed port required if provider expects an exact redirect URI + opts.Hostname4 = "localhost"; // Host portion of the redirect URI + opts.BindAddress4 = IPAddress.Loopback; // Listen only on loopback for safety +}); + +// Configure the expected callback URI (must match exactly what you registered with the OAuth provider) +services.AddOAuthCallbackHandlerAndRegister(o => +{ + o.CallbackUri = "http://localhost:5001/oauth/callback"; // Server will always use http scheme, no https! +}); + +var sp = services.BuildServiceProvider(); +var server = sp.GetRequiredService(); +var (uri4, _) = server.Start(); +Console.WriteLine($"Server started: {uri4}"); + +// Obtain the handler via DI and await the redirect +var authHandler = sp.GetRequiredService(); +var authResult = await authHandler.WaitForCallbackAsync(); + +switch (authResult.ResponseStatus) +{ + case WebAuthenticationStatus.Success: + Console.WriteLine("OAuth flow succeeded."); + break; + case WebAuthenticationStatus.UserCancel: + Console.WriteLine("User cancelled the flow."); + break; + case WebAuthenticationStatus.ErrorHttp: + Console.WriteLine($"HTTP error: {authResult.ResponseErrorDetail}"); + break; + default: + Console.WriteLine("Unexpected status."); + break; +} +``` + +> [!NOTE] +> +> - Ensure the fixed port and path (`/oauth/callback` in the examples) match exactly the redirect URI registered with the OAuth provider. +> - `WaitForCallbackAsync()` returns once the first matching request arrives; subsequent requests are ignored for result completion. +> - The handler sets a simple text response indicating success, cancellation, or error so users can close the browser tab. +> - When running tests or multiple local flows, prefer dynamic ports unless the provider requires a fixed one. +> [!IMPORTANT] +> Only one Server instance (per address family) can bind to a given fixed port. Attempting to start a second server on the same port will throw a System.Net.Sockets.SocketException (address already in use). diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/AuthCallbackHandlerOptionsTests.cs b/Yllibed.HttpServer.Handlers.Uno.Tests/AuthCallbackHandlerOptionsTests.cs new file mode 100644 index 0000000..c0e0928 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/AuthCallbackHandlerOptionsTests.cs @@ -0,0 +1,54 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Tests; + +public class AuthCallbackHandlerOptionsTests +{ + + private static List Validate(object model) + { + var results = new List(); + var context = new ValidationContext(model); + Validator.TryValidateObject(model, context, results, validateAllProperties: true); + return results; + } + + [Fact] + public void Validation_Fails_When_CallbackUri_Is_Null() + { + // Arrange + var opts = new AuthCallbackHandlerOptions { CallbackUri = null }; + + // Act + var results = Validate(opts); + + // Assert + results.ShouldNotBeEmpty(); + results.ShouldContain(r => r.MemberNames.Contains(nameof(AuthCallbackHandlerOptions.CallbackUri))); + } + + [Fact] + public void Validation_Fails_When_CallbackUri_Is_Invalid_Url() + { + // Arrange + var opts = new AuthCallbackHandlerOptions { CallbackUri = "not-a-url" }; + + // Act + var results = Validate(opts); + + // Assert + results.ShouldNotBeEmpty(); + results.ShouldContain(r => r.MemberNames.Contains(nameof(AuthCallbackHandlerOptions.CallbackUri))); + } + + [Fact] + public void Validation_Passes_With_Valid_Url() + { + // Arrange + var opts = new AuthCallbackHandlerOptions { CallbackUri = "http://example.com/callback" }; + + // Act + var results = Validate(opts); + + // Assert + results.ShouldBeEmpty(); + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/GlobalUsings.cs b/Yllibed.HttpServer.Handlers.Uno.Tests/GlobalUsings.cs new file mode 100644 index 0000000..a889de4 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using System.ComponentModel.DataAnnotations; +global using System.Net; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Options; +global using Yllibed.HttpServer.Extensions; +global using Yllibed.HttpServer.Handlers.Uno.Extensions; diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackExtensionsTests.cs b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackExtensionsTests.cs new file mode 100644 index 0000000..9897082 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackExtensionsTests.cs @@ -0,0 +1,93 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Tests; + +public class OAuthCallbackExtensionsTests +{ + [Fact] + public void AddOAuthCallbackHandler_RegistersHandlerAndInterface() + { + var services = new ServiceCollection(); + var options = new AuthCallbackHandlerOptions { CallbackUri = "http://localhost/callback" }; + services.AddSingleton(Options.Create(options)); + + services.AddOAuthCallbackHandler(); + + using var sp = services.BuildServiceProvider(); + var concrete = sp.GetService(); + var asInterface = sp.GetService(); + + concrete.ShouldNotBeNull(); + asInterface.ShouldNotBeNull(); + ReferenceEquals(concrete, asInterface).ShouldBeTrue(); + concrete!.CallbackUri.ShouldBe(new Uri("http://localhost/callback")); + } + + [Fact] + public async Task AddOAuthCallbackHandlerAndRegister_RegistersIntoServerPipeline() + { + var services = new ServiceCollection(); + services.AddLogging(); + + var namedOptions = new AuthCallbackHandlerOptions { CallbackUri = "http://localhost/callback" }; + services.AddSingleton(Options.Create(namedOptions)); + + services.AddYllibedHttpServer(); + services.AddOAuthCallbackHandlerAndRegister(); + + await using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + var (uri4, _) = server.Start(); + var callbackUri = new Uri(uri4, "/callback?code=abc"); + + var client = new HttpClient(); + var response = await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var handler = sp.GetRequiredService(); + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)200); + result.ResponseData.ShouldNotBeNull(); + result.ResponseData!.ShouldContain("code=abc"); + } + + [Fact] + public void AddOAuthCallbackHandlerAndRegister_WithConfigure_RegistersHandlerAndAppliesOptions() + { + var services = new ServiceCollection(); + + services.AddOAuthCallbackHandlerAndRegister(o => o.CallbackUri = "http://localhost/configured-callback"); + + using var sp = services.BuildServiceProvider(); + var concrete = sp.GetService(); + var asInterface = sp.GetService(); + + concrete.ShouldNotBeNull(); + asInterface.ShouldNotBeNull(); + ReferenceEquals(concrete, asInterface).ShouldBeTrue(); + concrete!.CallbackUri.ShouldBe(new Uri("http://localhost/configured-callback")); + } + + [Fact] + public async Task AddOAuthCallbackHandlerAndRegister_WithConfigure_RegistersIntoServerPipeline() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddYllibedHttpServer(); + services.AddOAuthCallbackHandlerAndRegister(o => o.CallbackUri = "http://localhost/configured-callback"); + + await using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + var (uri4, _) = server.Start(); + var callbackUri = new Uri(uri4, "/configured-callback?code=xyz"); + + var client = new HttpClient(); + var response = await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var handler = sp.GetRequiredService(); + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)200); + result.ResponseData.ShouldNotBeNull(); + result.ResponseData!.ShouldContain("code=xyz"); + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackHandlerTests.cs b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackHandlerTests.cs new file mode 100644 index 0000000..00c49c6 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackHandlerTests.cs @@ -0,0 +1,263 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Tests; + +using Microsoft.Extensions.Options; +using System.Net; +using Windows.Security.Authentication.Web; + +public class OAuthCallbackHandlerTests +{ + private const string ValidHttpCallback = "http://localhost:5000/callback"; + private const string ValidHttpsCallback = "https://localhost:5000/callback"; + + [Fact] + public void Constructor_WithValidHttpUri_Succeeds() + { + var uri = new Uri(ValidHttpCallback); + var handler = new OAuthCallbackHandler(uri); + + handler.CallbackUri.ShouldBe(uri); + } + + [Fact] + public void Constructor_WithValidHttpsUri_Succeeds() + { + var uri = new Uri(ValidHttpsCallback); + var handler = new OAuthCallbackHandler(uri); + + handler.CallbackUri.ShouldBe(uri); + } + + [Fact] + public void Constructor_WithNullUri_Throws() + { + Should.Throw(() => new OAuthCallbackHandler((Uri?)null!)) + .Message.ShouldContain("CallbackUri must be an absolute URI"); + } + + [Theory] + [InlineData("ftp://localhost:5000/callback")] + [InlineData("file:///callback")] + public void Constructor_WithInvalidScheme_Throws(string invalidUri) + { + Should.Throw(() => new OAuthCallbackHandler(new Uri(invalidUri))) + .Message.ShouldContain("CallbackUri must be an absolute URI with HTTP or HTTPS scheme"); + } + + [Fact] + public void Constructor_WithOptionsString_ValidHttpUri_Succeeds() + { + var options = new AuthCallbackHandlerOptions { CallbackUri = ValidHttpCallback }; + var handler = new OAuthCallbackHandler(options); + + handler.CallbackUri.ShouldBe(new Uri(ValidHttpCallback)); + } + + [Fact] + public void Constructor_WithOptionsString_ValidHttpsUri_Succeeds() + { + var options = new AuthCallbackHandlerOptions { CallbackUri = ValidHttpsCallback }; + var handler = new OAuthCallbackHandler(options); + + handler.CallbackUri.ShouldBe(new Uri(ValidHttpsCallback)); + } + + [Fact] + public void Constructor_WithOptionsString_NullCallbackUri_Throws() + { + var options = new AuthCallbackHandlerOptions { CallbackUri = null }; + Should.Throw(() => new OAuthCallbackHandler(options)) + .Message.ShouldContain("CallbackUri must be an absolute URI"); + } + + [Fact] + public void Constructor_WithOptionsString_InvalidScheme_Throws() + { + var options = new AuthCallbackHandlerOptions { CallbackUri = "ftp://localhost:5000/callback" }; + Should.Throw(() => new OAuthCallbackHandler(options)) + .Message.ShouldContain("CallbackUri must be an absolute URI with HTTP or HTTPS scheme"); + } + + [Fact] + public void Constructor_WithIOptions_ValidHttpUri_Succeeds() + { + var options = Options.Create(new AuthCallbackHandlerOptions { CallbackUri = ValidHttpCallback }); + var handler = new OAuthCallbackHandler(options); + + handler.CallbackUri.ShouldBe(new Uri(ValidHttpCallback)); + } + + [Fact] + public void Constructor_WithIOptions_ValidHttpsUri_Succeeds() + { + var options = Options.Create(new AuthCallbackHandlerOptions { CallbackUri = ValidHttpsCallback }); + var handler = new OAuthCallbackHandler(options); + + handler.CallbackUri.ShouldBe(new Uri(ValidHttpsCallback)); + } + + [Fact] + public void Constructor_WithIOptions_NullCallbackUri_Throws() + { + var options = Options.Create(new AuthCallbackHandlerOptions { CallbackUri = null }); + Should.Throw(() => new OAuthCallbackHandler(options)) + .Message.ShouldContain("CallbackUri must be an absolute URI"); + } + + [Fact] + public void Constructor_WithIOptions_InvalidScheme_Throws() + { + var options = Options.Create(new AuthCallbackHandlerOptions { CallbackUri = "ftp://localhost/callback" }); + Should.Throw(() => new OAuthCallbackHandler(options)) + .Message.ShouldContain("CallbackUri must be an absolute URI with HTTP or HTTPS scheme"); + } + + [Fact] + public async Task HandleRequest_WithSuccessCode_ReturnsSuccess() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + server.RegisterHandler(new StaticHandler("/fallback", "text/plain", "fallback")); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?code=auth-code-123"); + + using var client = new HttpClient(); + var response = await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var result = await handler.WaitForCallbackAsync(); + result.ResponseStatus.ShouldBe(WebAuthenticationStatus.Success); + result.ResponseErrorDetail.ShouldBe((uint)200); + } + + [Fact] + public async Task HandleRequest_WithAccessDeniedError_Returns403() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=access_denied"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseStatus.ShouldBe(WebAuthenticationStatus.UserCancel); + result.ResponseErrorDetail.ShouldBe((uint)403); + } + + [Fact] + public async Task HandleRequest_WithInvalidClientError_Returns401() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=invalid_client"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)401); + } + + [Fact] + public async Task HandleRequest_WithUnauthorizedClientError_Returns401() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=unauthorized_client"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)401); + } + + [Fact] + public async Task HandleRequest_WithInvalidScopeError_Returns401() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=invalid_scope"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)401); + } + + [Fact] + public async Task HandleRequest_WithTemporarilyUnavailableError_Returns503() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=temporarily_unavailable"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)503); + } + + [Fact] + public async Task HandleRequest_WithUnsupportedGrantTypeError_Returns500() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=unsupported_grant_type"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)500); + } + + [Fact] + public async Task HandleRequest_WithUnknownError_Returns400() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?error=unknown_error"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)400); + } + + [Fact] + public async Task HandleRequest_WithMultipleParameters_ParsesCorrectly() + { + var handler = new OAuthCallbackHandler(new Uri("http://localhost/callback")); + using var server = new Server(); + server.RegisterHandler(handler); + var (uri, _) = server.Start(); + var callbackUri = new Uri(uri, "/callback?code=auth-code-123&state=state-value&session_state=session"); + + using var client = new HttpClient(); + await client.GetAsync(callbackUri, TestContext.Current.CancellationToken); + + var result = await handler.WaitForCallbackAsync(); + result.ResponseData.ShouldNotBeNull(); + result.ResponseData.ShouldContain("code=auth-code-123"); + result.ResponseData.ShouldContain("state=state-value"); + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackReadmeExampleTests.cs b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackReadmeExampleTests.cs new file mode 100644 index 0000000..aea74c3 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/OAuthCallbackReadmeExampleTests.cs @@ -0,0 +1,78 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Tests; + +public class OAuthCallbackReadmeExampleTests +{ + [Fact] + public async Task README_OAuthCallback_Example_Works() + { + // Arrange + var services = new ServiceCollection(); + services.Configure(opts => + { + opts.Port = 0; // dynamic + opts.Hostname4 = "127.0.0.1"; + opts.Hostname6 = "::1"; + }); + services.AddSingleton(Options.Create(new AuthCallbackHandlerOptions + { + CallbackUri = "http://localhost/callback" + })); + services.AddYllibedHttpServer(); + services.AddOAuthCallbackHandlerAndRegister(); + await using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + + // Act + var (uri4, _) = server.Start(); + var callbackRequest = new Uri(uri4, "/callback?code=readme-test"); + using var client = new HttpClient(); + var response = await client.GetAsync(callbackRequest, TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + body.ShouldContain("Authentication completed successfully"); + var handler = sp.GetRequiredService(); + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)200); + result.ResponseData.ShouldNotBeNull(); + result.ResponseData!.ShouldContain("code=readme-test"); + } + + [Theory] + [InlineData("https://localhost:5001/etsy/callback", "etsy-theory-https")] + //[InlineData("http://localhost:5001/etsy/callback", "etsy-theory-http")] + public async Task OAuthCallback_Url_And_Configured_Server_Port_With_Loopback_Works(string callbackUri, string code) + { + // Arrange unified theory: dynamic server port, handler registered; callbackUri path match regardless of scheme/port + var services = new ServiceCollection(); + services.Configure(opts => + { + opts.Port = 5001; + opts.Hostname4 = "localhost"; + opts.BindAddress4 = IPAddress.Loopback; + }); + services.AddSingleton(Options.Create(new AuthCallbackHandlerOptions + { + CallbackUri = callbackUri, + })); + services.AddYllibedHttpServer(); + services.AddOAuthCallbackHandlerAndRegister(); + await using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + + // Act + var (uri4, _) = server.Start(); + var callbackRequest = new Uri(uri4, $"/etsy/callback?code={code}"); + using var client = new HttpClient(); + var response = await client.GetAsync(callbackRequest, TestContext.Current.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var handler = sp.GetRequiredService(); + var result = await handler.WaitForCallbackAsync(); + result.ResponseErrorDetail.ShouldBe((uint)200); + result.ResponseData.ShouldNotBeNull(); + result.ResponseData!.ShouldContain(code); + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/Yllibed.HttpServer.Handlers.Uno.Tests.csproj b/Yllibed.HttpServer.Handlers.Uno.Tests/Yllibed.HttpServer.Handlers.Uno.Tests.csproj new file mode 100644 index 0000000..6b08468 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/Yllibed.HttpServer.Handlers.Uno.Tests.csproj @@ -0,0 +1,39 @@ + + + + net9.0;net10.0 + + Exe + Yllibed.HttpServer.Handlers.Uno.Tests + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Yllibed.HttpServer.Handlers.Uno.Tests/xunit.runner.json b/Yllibed.HttpServer.Handlers.Uno.Tests/xunit.runner.json new file mode 100644 index 0000000..f3cb7c3 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno.Tests/xunit.runner.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "methodDisplay": "method", + "methodDisplayOptions": "replaceUnderscopreWithSpace", + "preEnumerateTheories": true, + "stopOnFail": true +} diff --git a/Yllibed.HttpServer.Handlers.Uno/AuthCallbackHandlerOptions.cs b/Yllibed.HttpServer.Handlers.Uno/AuthCallbackHandlerOptions.cs new file mode 100644 index 0000000..663c77e --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/AuthCallbackHandlerOptions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Yllibed.HttpServer.Handlers.Uno; + +public record AuthCallbackHandlerOptions +{ + public const string DefaultName = "AuthCallback"; + /// + /// Configures the expected URI for authentication Callbacks. + /// + [Required, Url] + public string? CallbackUri { get; set; } + +} diff --git a/Yllibed.HttpServer.Handlers.Uno/Defaults/OAuthErrorResponseDefaults.cs b/Yllibed.HttpServer.Handlers.Uno/Defaults/OAuthErrorResponseDefaults.cs new file mode 100644 index 0000000..5c74080 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/Defaults/OAuthErrorResponseDefaults.cs @@ -0,0 +1,19 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Defaults; +/// +/// OAuth Error Response standardized error codes. 4.1.2.1 Error Response +/// +public class OAuthErrorResponseDefaults +{ + public const string ErrorKey = "error"; + public const string ErrorDescriptionKey = "error_description"; + public const string ErrorUriKey = "error_uri"; + + public const string AccessDenied = "access_denied"; + public const string InvalidRequest = "invalid_request"; + public const string UnauthorizedClient = "unauthorized_client"; + public const string InvalidClient = "invalid_client"; + public const string InvalidGrant = "invalid_grant"; + public const string UnsupportedGrantType = "unsupported_grant_type"; + public const string InvalidScope = "invalid_scope"; + public const string TemporarilyUnavailable = "temporarily_unavailable"; +} diff --git a/Yllibed.HttpServer.Handlers.Uno/Extensions/OAuthCallbackExtensions.cs b/Yllibed.HttpServer.Handlers.Uno/Extensions/OAuthCallbackExtensions.cs new file mode 100644 index 0000000..7d63342 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/Extensions/OAuthCallbackExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Yllibed.HttpServer.Extensions; + +namespace Yllibed.HttpServer.Handlers.Uno.Extensions; + +/// +/// Extensions to configure OAuthCallbackHandler via Microsoft DI (aligned with GuardExtensions pattern). +/// +public static class OAuthCallbackExtensions +{ + /// + /// Registers OAuthCallbackHandler with options support and exposes it as both its concrete type, IAuthCallbackHandler, and IHttpHandler. + /// + public static IServiceCollection AddOAuthCallbackHandler(this IServiceCollection services) + { + services.AddSingleton(sp => new OAuthCallbackHandler(sp.GetRequiredService>())); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } + + /// + /// Registers OAuthCallbackHandler with configuration delegate. + /// + public static IServiceCollection AddOAuthCallbackHandler(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddOAuthCallbackHandler(); + } + + /// + /// Registers OAuthCallbackHandler and automatically wires it into the Server pipeline. + /// Avoids manual resolution and explicit Server.RegisterHandler calls by consumers. + /// Uses the automatic HandlerRegistrationService which ensures the handler is registered when Server is created. + /// + public static IServiceCollection AddOAuthCallbackHandlerAndRegister(this IServiceCollection services, Action? configure = null) + { + if (configure != null) + { + services.Configure(configure); + } + services.AddOAuthCallbackHandler(); + // Use the automatic registration mechanism provided by AddYllibedHttpServer + // This ensures handlers are registered when the Server is instantiated + // The GuardExtensions pattern was tested to always fail the test compared to this!! + services.AddHttpHandlerAndRegister(); + return services; + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno/Extensions/ServerOptionsExtensions.cs b/Yllibed.HttpServer.Handlers.Uno/Extensions/ServerOptionsExtensions.cs new file mode 100644 index 0000000..978701e --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/Extensions/ServerOptionsExtensions.cs @@ -0,0 +1,63 @@ +namespace Yllibed.HttpServer.Handlers.Uno.Extensions; + +public static class ServerOptionsExtensions +{ + /// + /// Creates an absolute URI using the IPv4 hostname and port specified in the given server options. + /// + /// The server options containing the IPv4 hostname and port to use for constructing the URI. Cannot be null. + /// A new instance representing the IPv4 address and port from the specified server options. + /// + /// Can be used to fill from existing . + /// + /// Thrown if is or empty. + /// Thrown if is . + /// Thrown if the constructed URI is not valid. + public static Uri ToUri4(this ServerOptions serverOptions, string relativePath) + { + var builder = new UriBuilder("http", serverOptions.Hostname4, serverOptions.Port, relativePath); + return new Uri(builder.ToString(), UriKind.Absolute); + } + + /// + /// Creates an absolute URI using the IPv6 hostname and port specified in the given server options. + /// + /// The server options containing the IPv6 hostname and port to use when constructing the URI. Cannot be null. + /// A new instance representing the server's IPv6 address and port. + /// + /// Can be used to fill from existing . + /// + /// Thrown if is or empty. + /// Thrown if is . + /// Thrown if the constructed URI is not valid. + public static Uri ToUri6(this ServerOptions serverOptions, string relativePath) + { + var builder = new UriBuilder("http", serverOptions.Hostname6, serverOptions.Port, relativePath); + return new Uri(builder.ToString(), UriKind.Absolute); + } + + /// + /// Combines the server's base URL with the specified relative path and returns the resulting absolute URL as a string. + /// + /// The server options containing the base URL to use for constructing the absolute URL. Cannot be null. + /// The relative path to append to the server's base URL. Must not be null or empty. + /// A string representing the absolute URL formed by combining the server's base URL with the specified relative path. + /// Thrown if is or empty. + /// Thrown if is . + /// Thrown if the constructed URL is not valid. + public static string ToUrl4(this ServerOptions serverOptions, string relativePath) + => serverOptions.ToUri4(relativePath).ToString(); + + /// + /// Creates an absolute URL string by combining the server's base address with the specified relative path using IPv6 + /// formatting. + /// + /// The server options containing the base address to use for constructing the URL. Cannot be null. + /// The relative path to append to the server's base address. Must not be null or empty. + /// A string representing the absolute URL formed by combining the base address from the server options with the specified relative path, using IPv6 formatting. + /// Thrown if is or empty. + /// Thrown if is . + /// Thrown if the constructed URL is not valid. + public static string ToUrl6(this ServerOptions serverOptions, string relativePath) + => serverOptions.ToUri6(relativePath).ToString(); +} diff --git a/Yllibed.HttpServer.Handlers.Uno/Extensions/UriExtensions.cs b/Yllibed.HttpServer.Handlers.Uno/Extensions/UriExtensions.cs new file mode 100644 index 0000000..4b57788 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/Extensions/UriExtensions.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; + +namespace Yllibed.HttpServer.Handlers.Uno.Extensions; + +public static partial class UriExtensions +{ +#if NET7_0_OR_GREATER + // Regex source generator: parses key=value pairs in a query string. Last value wins on duplicates. + [GeneratedRegex(@"([^?&=]+)=([^&]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture | RegexOptions.NonBacktracking)] + private static partial Regex QueryParameterRegex(); +#else + // Older frameworks don't have RegexOptions.NonBacktracking; fall back to equivalent safe options + private static readonly Regex _queryParameterRegex = new(@"([^?&=]+)=([^&]*)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture); + private static Regex QueryParameterRegex() => _queryParameterRegex; +#endif + + /// + /// Parses the query parameters from the given into a dictionary. + /// + /// The from which to extract query parameters. + /// A containing the query parameters as key-value pairs. + public static IDictionary GetParameters(this Uri uri) + { + return QueryParameterRegex() + .Matches(uri.Query) + .Cast() + .Select(m => new KeyValuePair( + Uri.UnescapeDataString(m.Groups[1].Value), + Uri.UnescapeDataString(m.Groups[2].Value))) + .GroupBy(kv => kv.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Last().Value, StringComparer.Ordinal); + } +} diff --git a/Yllibed.HttpServer.Handlers.Uno/GlobalUsings.cs b/Yllibed.HttpServer.Handlers.Uno/GlobalUsings.cs new file mode 100644 index 0000000..c9ddf37 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +global using Windows.Security.Authentication.Web; +global using Microsoft.Extensions.DependencyInjection; +global using Yllibed.HttpServer.Handlers.Uno.Defaults; diff --git a/Yllibed.HttpServer.Handlers.Uno/IAuthCallbackHandler.cs b/Yllibed.HttpServer.Handlers.Uno/IAuthCallbackHandler.cs new file mode 100644 index 0000000..3e57bb0 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/IAuthCallbackHandler.cs @@ -0,0 +1,7 @@ +namespace Yllibed.HttpServer.Handlers.Uno; + +public interface IAuthCallbackHandler : IHttpHandler +{ + public Uri CallbackUri { get; } + public Task WaitForCallbackAsync(); +} diff --git a/Yllibed.HttpServer.Handlers.Uno/OAuthCallbackHandler.cs b/Yllibed.HttpServer.Handlers.Uno/OAuthCallbackHandler.cs new file mode 100644 index 0000000..d1b95cc --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/OAuthCallbackHandler.cs @@ -0,0 +1,99 @@ +namespace Yllibed.HttpServer.Handlers.Uno; + +public record OAuthCallbackHandler : IAuthCallbackHandler +{ + private readonly TaskCompletionSource _tcs = new(); + public Uri CallbackUri { get; init; } + + public OAuthCallbackHandler(Uri callbackUri) + { + if (callbackUri is null || callbackUri.Scheme != Uri.UriSchemeHttp && callbackUri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The CallbackUri must be an absolute URI with HTTP or HTTPS scheme.", nameof(callbackUri)); + } + CallbackUri = callbackUri; + } + + public OAuthCallbackHandler( + AuthCallbackHandlerOptions options) + { + if (options.CallbackUri is null + || !Uri.TryCreate(options?.CallbackUri, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The CallbackUri must be an absolute URI with HTTP or HTTPS scheme.", nameof(options)); + } + CallbackUri = uri; + } + [ActivatorUtilitiesConstructor] + public OAuthCallbackHandler( + IOptions options) + { + if (options.Value.CallbackUri is null + || !Uri.TryCreate(options.Value.CallbackUri, UriKind.Absolute, out var uri) + || uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("The CallbackUri must be an absolute URI with HTTP or HTTPS scheme.", nameof(options)); + } + CallbackUri = uri; + } + + public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + { + if (request.Url.AbsolutePath.StartsWith(CallbackUri.AbsolutePath, StringComparison.OrdinalIgnoreCase)) + { + + var parameters = request.Url.GetParameters(); + + var statusCode = GetStatusCode(parameters); + + var result = GetWebAuthenticationResult(statusCode, request.Url.OriginalString); + + _tcs.TrySetResult(result); + request.SetResponse( + System.Net.Mime.MediaTypeNames.Text.Plain, + GetWebAuthenticationResponseMessage(result)); + } + + return Task.CompletedTask; + } + private WebAuthenticationResult GetWebAuthenticationResult(uint statusCode, string requestUriString) + { + return statusCode switch + { + 200 => new WebAuthenticationResult(requestUriString, statusCode, WebAuthenticationStatus.Success), + 403 => new WebAuthenticationResult(requestUriString, statusCode, WebAuthenticationStatus.UserCancel), + _ => new WebAuthenticationResult(requestUriString, statusCode, WebAuthenticationStatus.ErrorHttp), + }; + } + + private uint GetStatusCode(IDictionary parameters) + { + if (parameters.TryGetValue(OAuthErrorResponseDefaults.ErrorKey, out var error)) + { + return error switch + { + OAuthErrorResponseDefaults.AccessDenied => 403, + OAuthErrorResponseDefaults.InvalidClient or OAuthErrorResponseDefaults.UnauthorizedClient or OAuthErrorResponseDefaults.InvalidScope => 401, + OAuthErrorResponseDefaults.TemporarilyUnavailable => 503, + OAuthErrorResponseDefaults.UnsupportedGrantType => 500, + _ => 400 // For all others: Bad Request + }; + + } + + return 200; + } + private string GetWebAuthenticationResponseMessage(WebAuthenticationResult result) + { + return result.ResponseStatus switch + { + WebAuthenticationStatus.Success => "Authentication completed successfully - you can close this browser now.", + WebAuthenticationStatus.UserCancel => "Authentication was cancelled by the user.", + WebAuthenticationStatus.ErrorHttp => "Authentication failed due to an error.", + _ => "Authentication completed - you can close this browser now." + }; + } + public Task WaitForCallbackAsync() => _tcs.Task; + +} diff --git a/Yllibed.HttpServer.Handlers.Uno/Yllibed.HttpServer.Handlers.Uno.csproj b/Yllibed.HttpServer.Handlers.Uno/Yllibed.HttpServer.Handlers.Uno.csproj new file mode 100644 index 0000000..582e240 --- /dev/null +++ b/Yllibed.HttpServer.Handlers.Uno/Yllibed.HttpServer.Handlers.Uno.csproj @@ -0,0 +1,15 @@ + + + + net9.0;net10.0 + enable + enable + $(Authors);Sonja Schweitzer + + + + + + + + diff --git a/Yllibed.HttpServer.slnx b/Yllibed.HttpServer.slnx index a832032..333d3cd 100644 --- a/Yllibed.HttpServer.slnx +++ b/Yllibed.HttpServer.slnx @@ -8,8 +8,10 @@ + + \ No newline at end of file diff --git a/Yllibed.HttpServer/GlobalUsings.cs b/Yllibed.HttpServer/GlobalUsings.cs index 5d894fe..917a18b 100644 --- a/Yllibed.HttpServer/GlobalUsings.cs +++ b/Yllibed.HttpServer/GlobalUsings.cs @@ -5,3 +5,4 @@ global using System.Threading; global using System.Threading.Tasks; global using Yllibed.HttpServer.Extensions; +global using Yllibed.HttpServer.Handlers; diff --git a/global.json b/global.json index 3c5cf51..701ca2d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,9 @@ { + "msbuild-sdks": { + "Uno.Sdk": "6.4.24" + }, "sdk": { - "version": "9.0.0", + "version": "9.0.100", "rollForward": "latestMajor", "allowPrerelease": false }