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
}