diff --git a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj
index 172a01b4..f8d018b7 100644
--- a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj
+++ b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj
@@ -14,7 +14,7 @@
9.3.0
Andreas Nägeli
-
+
LICENSE
https://genhttp.org/
@@ -35,20 +35,20 @@
-
-
+
+
-
+
-
+
diff --git a/Modules/Authentication/Multi/MultiAuthenticationConcern.cs b/Modules/Authentication/Multi/MultiAuthenticationConcern.cs
new file mode 100644
index 00000000..af1682d5
--- /dev/null
+++ b/Modules/Authentication/Multi/MultiAuthenticationConcern.cs
@@ -0,0 +1,83 @@
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Protocol;
+
+namespace GenHTTP.Modules.Authentication.Multi;
+
+public sealed class MultiAuthenticationConcern : IConcern
+{
+ #region Get-/Setters
+
+ public IHandler Content { get; }
+
+ private readonly IConcern[] _delegatingConcerns;
+
+ #endregion
+
+ #region Initialization
+
+ public MultiAuthenticationConcern(IHandler content, IConcern[] delegatingConcerns)
+ {
+ Content = content;
+ _delegatingConcerns = delegatingConcerns;
+ }
+
+ #endregion
+
+ #region Functionality
+
+ public async ValueTask HandleAsync(IRequest request)
+ {
+ ResponseOrException? lastResponse = null;
+ foreach (var concern in _delegatingConcerns)
+ {
+ lastResponse?.Dispose();
+
+ try
+ {
+ lastResponse = new(await concern.HandleAsync(request));
+ }
+ catch (ProviderException e)
+ {
+ lastResponse = new(Exception: e);
+ }
+
+ if (lastResponse.Status != ResponseStatus.Unauthorized)
+ {
+ return lastResponse.Get();
+ }
+ }
+
+ return lastResponse?.Get() ??
+ request.Respond()
+ .Status(ResponseStatus.Unauthorized)
+ .Build();
+ }
+
+ public ValueTask PrepareAsync() => Content.PrepareAsync();
+
+ #endregion
+
+ #region Helper structure
+
+ private sealed record ResponseOrException(IResponse? Response = null, ProviderException? Exception = null) : IDisposable
+ {
+ public IResponse? Get()
+ {
+ if (Exception != null)
+ {
+ throw Exception;
+ }
+
+ return Response;
+ }
+
+ public void Dispose()
+ {
+ Response?.Dispose();
+ }
+
+ public ResponseStatus? Status => Exception?.Status ?? Response?.Status.KnownStatus;
+ }
+
+ #endregion
+}
diff --git a/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs b/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs
new file mode 100644
index 00000000..8eaaac08
--- /dev/null
+++ b/Modules/Authentication/Multi/MultiAuthenticationConcernBuilder.cs
@@ -0,0 +1,88 @@
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Modules.Authentication.ApiKey;
+using GenHTTP.Modules.Authentication.Basic;
+using GenHTTP.Modules.Authentication.Bearer;
+using GenHTTP.Modules.Authentication.ClientCertificate;
+
+namespace GenHTTP.Modules.Authentication.Multi;
+
+///
+/// Builder for creating a multi-concern authentication handler.
+///
+public sealed class MultiAuthenticationConcernBuilder : IConcernBuilder
+{
+ #region Fields
+
+ private readonly List _delegatingConcernsBuilders = [];
+
+ #endregion
+
+ #region Private add functionality
+
+ private MultiAuthenticationConcernBuilder AddIfNotNull(IConcernBuilder concernBuilder)
+ {
+ if (concernBuilder == null) return this;
+
+ _delegatingConcernsBuilders.Add(concernBuilder);
+ return this;
+ }
+
+ #endregion
+
+ #region Functionality
+
+ ///
+ /// Add nested API concern builder to this multi concern.
+ ///
+ /// Nested API key concern builder
+ public MultiAuthenticationConcernBuilder Add(ApiKeyConcernBuilder apiKeyBuilder)
+ => AddIfNotNull(apiKeyBuilder);
+
+ ///
+ /// Add nested API concern builder to this multi concern.
+ ///
+ /// Nested Basic concern builder
+ public MultiAuthenticationConcernBuilder Add(BasicAuthenticationConcernBuilder basicBuilder)
+ => AddIfNotNull(basicBuilder);
+
+ ///
+ /// Add nested API concern builder to this multi concern.
+ ///
+ /// Nested Basic Known Users concern builder
+ public MultiAuthenticationConcernBuilder Add(BasicAuthenticationKnownUsersBuilder basicKnownUsersBuilder)
+ => AddIfNotNull(basicKnownUsersBuilder);
+
+ ///
+ /// Add nested API concern builder to this multi concern.
+ ///
+ /// Nested Bearer concern builder
+ public MultiAuthenticationConcernBuilder Add(BearerAuthenticationConcernBuilder bearerBuilder)
+ => AddIfNotNull(bearerBuilder);
+
+ ///
+ /// Add nested API concern builder to this multi concern.
+ ///
+ /// Nested Client Certificate concern builder
+ public MultiAuthenticationConcernBuilder Add(ClientCertificateAuthenticationBuilder clientCertificateBuilder)
+ => AddIfNotNull(clientCertificateBuilder);
+
+ ///
+ /// Construct the multi concern.
+ ///
+ ///
+ ///
+ public IConcern Build(IHandler content)
+ {
+ var delegatingConcerns = _delegatingConcernsBuilders.Select(x => x.Build(content)).ToArray();
+ if (delegatingConcerns.Length == 0) throw new BuilderMissingPropertyException("Concerns");
+
+ return new MultiAuthenticationConcern(
+ content,
+ delegatingConcerns
+ );
+ }
+
+ #endregion
+
+}
diff --git a/Modules/Authentication/MultiAuthentication.cs b/Modules/Authentication/MultiAuthentication.cs
new file mode 100644
index 00000000..007ffe63
--- /dev/null
+++ b/Modules/Authentication/MultiAuthentication.cs
@@ -0,0 +1,16 @@
+using GenHTTP.Modules.Authentication.Multi;
+
+namespace GenHTTP.Modules.Authentication;
+
+public static class MultiAuthentication
+{
+ #region Builder
+
+ ///
+ /// Creates a authentication handler that will use
+ /// underlying handlers to authenticate the request.
+ ///
+ public static MultiAuthenticationConcernBuilder Create() => new();
+
+ #endregion
+}
diff --git a/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs
new file mode 100644
index 00000000..7e05f2fc
--- /dev/null
+++ b/Testing/Acceptance/Modules/Authentication/MultiAuthenticationTests.cs
@@ -0,0 +1,202 @@
+using GenHTTP.Api.Content;
+using GenHTTP.Api.Content.Authentication;
+using GenHTTP.Api.Infrastructure;
+using GenHTTP.Api.Protocol;
+using GenHTTP.Modules.Authentication;
+using GenHTTP.Modules.Authentication.Multi;
+using GenHTTP.Modules.Functional;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace GenHTTP.Testing.Acceptance.Modules.Authentication;
+
+[TestClass]
+public sealed class MultiAuthenticationTests
+{
+ #region Supporting data structures
+
+ private const string ValidBearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
+
+ private class MockHandler : IHandler
+ {
+ public ValueTask HandleAsync(IRequest request)
+ {
+ throw new NotImplementedException();
+ }
+
+ public ValueTask PrepareAsync()
+ {
+ throw new NotImplementedException();
+ }
+ }
+
+ #endregion
+
+ [TestMethod]
+ public void TestConcernFullBuild()
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create())
+ .Add(ApiKeyAuthentication.Create().Authenticator((_, _) => ValueTask.FromResult(null)))
+ .Add(BasicAuthentication.Create())
+ .Add(BasicAuthentication.Create((_, _) => ValueTask.FromResult(null)))
+ .Add(ClientCertificateAuthentication.Create())
+ ;
+
+ var concern = builder.Build(new MockHandler());
+
+ Assert.IsInstanceOfType(concern);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestSingleBearerPass(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create().AllowExpired())
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ValidBearerToken));
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual("Secured", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestSingleBearerFail(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create().AllowExpired())
+ ;
+
+ using var response = await Execute(builder, engine);
+
+ await response.AssertStatusAsync(HttpStatusCode.Unauthorized);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestSingleBasicPass(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BasicAuthentication.Create().Add("user", "pass"))
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"))));
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual("Secured", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestSingleBasicFail(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BasicAuthentication.Create().Add("user", "pass"))
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:invalidpass"))));
+
+ await response.AssertStatusAsync(HttpStatusCode.Unauthorized);
+ AssertBasicChallenge(response);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestCombinedFirstPass(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create().AllowExpired())
+ .Add(BasicAuthentication.Create().Add("user", "pass"))
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ValidBearerToken));
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual("Secured", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestCombinedSecondPass(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create().AllowExpired())
+ .Add(BasicAuthentication.Create().Add("user", "pass"))
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass"))));
+
+ await response.AssertStatusAsync(HttpStatusCode.OK);
+
+ Assert.AreEqual("Secured", await response.GetContentAsync());
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public async Task TestCombinedFail(TestEngine engine)
+ {
+ var builder = MultiAuthentication
+ .Create()
+ .Add(BearerAuthentication.Create().AllowExpired())
+ .Add(BasicAuthentication.Create().Add("user", "pass"))
+ ;
+
+ using var response = await Execute(builder, engine,
+ request => request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("user:invalidpass"))));
+
+ await response.AssertStatusAsync(HttpStatusCode.Unauthorized);
+ AssertBasicChallenge(response);
+ }
+
+ [TestMethod]
+ [MultiEngineTest]
+ public void TestEmpty(TestEngine engine)
+ {
+ var builder = MultiAuthentication.Create();
+
+ Assert.ThrowsException(() =>
+ {
+ using var response = Execute(builder, engine).GetAwaiter().GetResult();
+ });
+ }
+
+ private static void AssertBasicChallenge(HttpResponseMessage response)
+ {
+ Assert.IsNotNull(response.Headers.WwwAuthenticate.FirstOrDefault(x => x.Scheme == "Basic" && (x.Parameter?.StartsWith("realm") ?? false)));
+ }
+
+ private static async Task Execute(MultiAuthenticationConcernBuilder builder, TestEngine engine, Action? authAction = null)
+ {
+ var handler = Inline.Create()
+ .Get(() => "Secured")
+ .Add(builder);
+
+ await using var host = await TestHost.RunAsync(handler, engine: engine);
+
+ var request = host.GetRequest();
+
+ authAction?.Invoke(request);
+
+ return await host.GetResponseAsync(request);
+ }
+}