From 0c9a8f9b2f1df9e14b7e4ad3b42ca30ac2bf93f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Mon, 1 Jul 2024 12:42:43 +0200 Subject: [PATCH] Add bearer authentication concern (#506) * First draft for bearer auth * Additional coverage and documentation * Fix test --- .../Bearer/BearerAuthenticationConcern.cs | 167 ++++++++++++++++++ .../BearerAuthenticationConcernBuilder.cs | 98 ++++++++++ .../Bearer/TokenValidationOptions.cs | 26 +++ .../Authentication/BearerAuthentication.cs | 19 ++ .../GenHTTP.Modules.Authentication.csproj | 4 + .../BearerAuthenticationTests.cs | 125 +++++++++++++ 6 files changed, 439 insertions(+) create mode 100644 Modules/Authentication/Bearer/BearerAuthenticationConcern.cs create mode 100644 Modules/Authentication/Bearer/BearerAuthenticationConcernBuilder.cs create mode 100644 Modules/Authentication/Bearer/TokenValidationOptions.cs create mode 100644 Modules/Authentication/BearerAuthentication.cs create mode 100644 Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs diff --git a/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs b/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs new file mode 100644 index 00000000..d93d6578 --- /dev/null +++ b/Modules/Authentication/Bearer/BearerAuthenticationConcern.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Tokens; + +namespace GenHTTP.Modules.Authentication.Bearer +{ + + #region Supporting data structures + + internal class OpenIDConfiguration + { + + [JsonPropertyName("jwks_uri")] + public string? KeySetUrl { get; set; } + + } + + #endregion + + internal sealed class BearerAuthenticationConcern : IConcern + { + private ICollection? _IssuerKeys = null; + + #region Get-/Setters + + public IHandler Content { get; } + + public IHandler Parent { get; } + + private TokenValidationOptions ValidationOptions { get; } + + #endregion + + #region Initialization + + internal BearerAuthenticationConcern(IHandler parent, Func contentFactory, TokenValidationOptions validationOptions) + { + Parent = parent; + Content = contentFactory(this); + + ValidationOptions = validationOptions; + } + + #endregion + + #region Functionality + + public ValueTask PrepareAsync() => Content.PrepareAsync(); + + public IAsyncEnumerable GetContentAsync(IRequest request) => Content.GetContentAsync(request); + + public async ValueTask HandleAsync(IRequest request) + { + IdentityModelEventSource.LogCompleteSecurityArtifact = true; + + if (!request.Headers.TryGetValue("Authorization", out var authHeader)) + { + throw new ProviderException(ResponseStatus.Unauthorized, "This endpoint requires authorization"); + } + + if (!authHeader.StartsWith("Bearer ")) + { + throw new ProviderException(ResponseStatus.Unauthorized, "This endpoint requires bearer token authentication"); + } + + var tokenString = authHeader[7..]; + + var tokenHandler = new JwtSecurityTokenHandler(); + + if (!tokenHandler.CanReadToken(tokenString)) + { + throw new ProviderException(ResponseStatus.BadRequest, "Malformed authentication token"); + } + + var issuer = ValidationOptions.Issuer; + + var audience = ValidationOptions.Audience; + + if ((issuer != null) && (_IssuerKeys == null)) + { + _IssuerKeys = await FetchSigningKeys(issuer); + } + + var validationParameters = new TokenValidationParameters() + { + ValidIssuer = issuer, + ValidateIssuer = (issuer != null), + IssuerSigningKeys = _IssuerKeys, + ValidAudience = audience, + ValidateAudience = (audience != null), + ValidateLifetime = ValidationOptions.Lifetime + }; + + if (issuer == null) + { + validationParameters.SignatureValidator = (string token, TokenValidationParameters p) => new JwtSecurityToken(tokenString); + } + + try + { + tokenHandler.ValidateToken(tokenString, validationParameters, out var validated); + + var jwt = (JwtSecurityToken)validated; + + if (ValidationOptions.CustomValidator != null) + { + await ValidationOptions.CustomValidator.Invoke(jwt); + } + + if (ValidationOptions.UserMapping != null) + { + var user = await ValidationOptions.UserMapping.Invoke(request, jwt); + + if (user != null) + { + request.SetUser(user); + } + } + + return await Content.HandleAsync(request); + } + catch (SecurityTokenValidationException ex) + { + throw new ProviderException(ResponseStatus.Unauthorized, $"Authorization failed: {ex.Message}", ex); + } + } + + private static async Task> FetchSigningKeys(string issuer) + { + try + { + var configUrl = $"{issuer}/.well-known/openid-configuration"; + + using var httpClient = new HttpClient(); + + var configResponse = await httpClient.GetStringAsync(configUrl); + + var config = JsonSerializer.Deserialize(configResponse) + ?? throw new InvalidOperationException($"Unable to discover configuration via '{configUrl}'"); + + var keyResponse = await httpClient.GetStringAsync(config.KeySetUrl); + + var keySet = new JsonWebKeySet(keyResponse); + + return keySet.GetSigningKeys(); + } + catch (Exception ex) + { + throw new ProviderException(ResponseStatus.InternalServerError, "Unable to fetch signing issuer signing keys", ex); + } + } + + } + + #endregion + +} diff --git a/Modules/Authentication/Bearer/BearerAuthenticationConcernBuilder.cs b/Modules/Authentication/Bearer/BearerAuthenticationConcernBuilder.cs new file mode 100644 index 00000000..cdb1b9ef --- /dev/null +++ b/Modules/Authentication/Bearer/BearerAuthenticationConcernBuilder.cs @@ -0,0 +1,98 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.Authentication; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Modules.Authentication.Bearer +{ + + public sealed class BearerAuthenticationConcernBuilder : IConcernBuilder + { + private readonly TokenValidationOptions _Options = new(); + + #region Functionality + + /// + /// Sets the expected issuer. Tokens that are not issued by this + /// party will be declined. + /// + /// The URL of the exepcted issuer + /// + /// Setting the issuer will cause the concern to download and cache + /// the signing keys that are used to ensure that the party actually + /// issued a token. + /// + public BearerAuthenticationConcernBuilder Issuer(string issuer) + { + _Options.Issuer = issuer; + return this; + } + + /// + /// Sets the expected audience that should be accepted. + /// + /// The audience to check for + public BearerAuthenticationConcernBuilder Audience(string audience) + { + _Options.Audience = audience; + return this; + } + + /// + /// Adds a custom validator that can analyze the token read from the + /// request and can perform additional checks. + /// + /// The custom validator to be used + /// + /// This validator will be invoked after the regular checks (such as the + /// issuer) have been performed. + /// + /// If you would like to deny user access within a custom validator, + /// you can throw a . + /// + public BearerAuthenticationConcernBuilder Validation(Func validator) + { + _Options.CustomValidator = validator; + return this; + } + + /// + /// Optionally register a function that will compute the user that + /// should be set for the request. + /// + /// The user mapping to be used + /// + /// The usage of this mechanism allows to inject the user into + /// service methods via the user injector class. Returning null + /// within the delegate will not deny user access - if you would + /// like to prevent such user, you can throw a . + /// + public BearerAuthenticationConcernBuilder UserMapping(Func> userMapping) + { + _Options.UserMapping = userMapping; + return this; + } + + /// + /// If enabled, tokens that have expired or are not valid yet are + /// still accepted. This should be used for testing purposes only. + /// + public BearerAuthenticationConcernBuilder AllowExpired() + { + _Options.Lifetime = false; + return this; + } + + public IConcern Build(IHandler parent, Func contentFactory) + { + return new BearerAuthenticationConcern(parent, contentFactory, _Options); + } + + #endregion + + } + +} diff --git a/Modules/Authentication/Bearer/TokenValidationOptions.cs b/Modules/Authentication/Bearer/TokenValidationOptions.cs new file mode 100644 index 00000000..d1aab100 --- /dev/null +++ b/Modules/Authentication/Bearer/TokenValidationOptions.cs @@ -0,0 +1,26 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Threading.Tasks; + +using GenHTTP.Api.Content.Authentication; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Modules.Authentication.Bearer +{ + + internal sealed class TokenValidationOptions + { + + internal string? Audience { get; set; } + + internal string? Issuer { get; set; } + + internal bool Lifetime { get; set; } = true; + + internal Func? CustomValidator { get; set; } + + internal Func>? UserMapping { get; set; } + + } + +} diff --git a/Modules/Authentication/BearerAuthentication.cs b/Modules/Authentication/BearerAuthentication.cs new file mode 100644 index 00000000..1a92a6dd --- /dev/null +++ b/Modules/Authentication/BearerAuthentication.cs @@ -0,0 +1,19 @@ +using GenHTTP.Modules.Authentication.Bearer; + +namespace GenHTTP.Modules.Authentication +{ + + public static class BearerAuthentication + { + + /// + /// Creates a concern that will read an access token from + /// the authorization headers and validate it according to + /// its configuration. + /// + /// The newly created concern + public static BearerAuthenticationConcernBuilder Create() => new(); + + } + +} diff --git a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj index 4a0b9bfa..06218c0d 100644 --- a/Modules/Authentication/GenHTTP.Modules.Authentication.csproj +++ b/Modules/Authentication/GenHTTP.Modules.Authentication.csproj @@ -42,8 +42,12 @@ + + + + diff --git a/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs new file mode 100644 index 00000000..b23c5125 --- /dev/null +++ b/Testing/Acceptance/Modules/Authentication/BearerAuthenticationTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.Authentication; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Authentication; +using GenHTTP.Modules.Authentication.Bearer; +using GenHTTP.Modules.Functional; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.Authentication +{ + + [TestClass] + public sealed class BearerAuthenticationTests + { + private const string VALID_TOKEN = @"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + #region Supporting data structures + + internal class MyUser : IUser + { + + public string DisplayName { get; set; } = ""; + + } + + #endregion + + [TestMethod] + public async Task TestValidToken() + { + var auth = BearerAuthentication.Create() + .AllowExpired(); + + using var response = await Execute(auth, VALID_TOKEN); + + await response.AssertStatusAsync(HttpStatusCode.OK); + + Assert.AreEqual("Secured", await response.GetContentAsync()); + } + + [TestMethod] + public async Task TestCustomValidator() + { + var auth = BearerAuthentication.Create() + .Validation((token) => throw new ProviderException(ResponseStatus.Forbidden, "Nah")) + .AllowExpired(); + + using var response = await Execute(auth, VALID_TOKEN); + + await response.AssertStatusAsync(HttpStatusCode.Forbidden); + } + + [TestMethod] + public async Task TestNoUser() + { + var auth = BearerAuthentication.Create() + .UserMapping((r, t) => new()) + .AllowExpired(); + + using var response = await Execute(auth, VALID_TOKEN); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + [TestMethod] + public async Task TestUser() + { + var auth = BearerAuthentication.Create() + .UserMapping((r, t) => new(new MyUser() { DisplayName = "User Name" })) + .AllowExpired(); + + using var response = await Execute(auth, VALID_TOKEN); + + await response.AssertStatusAsync(HttpStatusCode.OK); + } + + [TestMethod] + public async Task TestNoToken() + { + var auth = BearerAuthentication.Create() + .AllowExpired(); + + using var response = await Execute(auth); + + await response.AssertStatusAsync(HttpStatusCode.Unauthorized); + } + + [TestMethod] + public async Task TestMalformedToken() + { + var auth = BearerAuthentication.Create() + .AllowExpired(); + + using var response = await Execute(auth, "Lorem Ipsum"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + private static async Task Execute(BearerAuthenticationConcernBuilder builder, string? token = null) + { + var handler = Inline.Create() + .Get(() => "Secured") + .Add(builder); + + using var host = TestHost.Run(handler); + + var request = host.GetRequest(); + + if (token != null) + { + request.Headers.Authorization = new("Bearer", token); + } + + return await host.GetResponseAsync(request); + } + + } + +}