From e257dba19a43224b1e8643f31939d5167b65ed84 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Thu, 11 Jun 2026 10:11:39 +0200 Subject: [PATCH 1/3] Update dependencies and improve authentication security - Bump NuGet package versions across all projects - Use constant-time comparison for API key and password checks - Refactor authentication result handling for clarity - Change claims parameters to IEnumerable for flexibility - Improve issuer/audience validation logic in JwtBearerService - Remove explicit culture from Basic auth regex Closes #196 --- README.md | 2 +- .../ApiKeySample/ApiKeySample.csproj | 4 +- .../BasicAuthenticationSample.csproj | 4 +- .../JwtBearerSample/JwtBearerSample.csproj | 4 +- .../ApiKeySample/ApiKeySample.csproj | 4 +- samples/MinimalApis/ApiKeySample/Program.cs | 9 +++- .../BasicAuthenticationSample.csproj | 4 +- .../JwtBearerSample/JwtBearerSample.csproj | 4 +- .../Net8JwtBearerSample.csproj | 2 +- .../SimpleAuthentication.Swashbuckle.csproj | 4 +- .../ApiKey/ApiKeyAuthenticationHandler.cs | 42 ++++++++++-------- .../BasicAuthenticationHandler.cs | 44 ++++++++++--------- .../JwtBearer/JwtBearerService.cs | 16 +++---- .../SimpleAuthentication.csproj | 6 +-- 14 files changed, 81 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 393cbf6..d2cbe52 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Lint Code Base](https://github.com/marcominerva/SimpleAuthentication/actions/workflows/linter.yml/badge.svg)](https://github.com/marcominerva/SimpleAuthentication/actions/workflows/linter.yml) [![CodeQL](https://github.com/marcominerva/SimpleAuthentication/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/marcominerva/SimpleAuthentication/actions/workflows/github-code-scanning/codeql) [![Nuget](https://img.shields.io/nuget/v/SimpleAuthenticationTools)](https://www.nuget.org/packages/SimpleAuthenticationTools) -[![Nuget](https://img.shields.io/nuget/dt/SimpleAuthenticationTools)](https://www.nuget.org/packages/SimpleAuthenticationTools) +[![NuGet](https://img.shields.io/nuget/dt/SimpleAuthenticationTools)](https://www.nuget.org/packages/SimpleAuthenticationTools) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/marcominerva/SimpleAuthentication/blob/master/LICENSE) A library to easily integrate Authentication in ASP.NET Core projects. Currently it supports JWT Bearer, API Key and Basic Authentication in both Controller-based and Minimal API projects. diff --git a/samples/Controllers/ApiKeySample/ApiKeySample.csproj b/samples/Controllers/ApiKeySample/ApiKeySample.csproj index a312592..b83d877 100644 --- a/samples/Controllers/ApiKeySample/ApiKeySample.csproj +++ b/samples/Controllers/ApiKeySample/ApiKeySample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/Controllers/BasicAuthenticationSample/BasicAuthenticationSample.csproj b/samples/Controllers/BasicAuthenticationSample/BasicAuthenticationSample.csproj index a312592..b83d877 100644 --- a/samples/Controllers/BasicAuthenticationSample/BasicAuthenticationSample.csproj +++ b/samples/Controllers/BasicAuthenticationSample/BasicAuthenticationSample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/Controllers/JwtBearerSample/JwtBearerSample.csproj b/samples/Controllers/JwtBearerSample/JwtBearerSample.csproj index a312592..b83d877 100644 --- a/samples/Controllers/JwtBearerSample/JwtBearerSample.csproj +++ b/samples/Controllers/JwtBearerSample/JwtBearerSample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/MinimalApis/ApiKeySample/ApiKeySample.csproj b/samples/MinimalApis/ApiKeySample/ApiKeySample.csproj index b50f1e3..aa34a16 100644 --- a/samples/MinimalApis/ApiKeySample/ApiKeySample.csproj +++ b/samples/MinimalApis/ApiKeySample/ApiKeySample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/MinimalApis/ApiKeySample/Program.cs b/samples/MinimalApis/ApiKeySample/Program.cs index ee0c051..9b3f128 100644 --- a/samples/MinimalApis/ApiKeySample/Program.cs +++ b/samples/MinimalApis/ApiKeySample/Program.cs @@ -80,10 +80,15 @@ public class CustomApiKeyValidator : IApiKeyValidator { public Task ValidateAsync(string apiKey) { + var claims = new[] + { + new Claim(ClaimTypes.Role, "User") + }; + var result = apiKey switch { - "ArAilHVOoL3upX78Cohq" => ApiKeyValidationResult.Success("User 1"), - "DiUU5EqImTYkxPDAxBVS" => ApiKeyValidationResult.Success("User 2"), + "ArAilHVOoL3upX78Cohq" => ApiKeyValidationResult.Success("User 1", claims), + "DiUU5EqImTYkxPDAxBVS" => ApiKeyValidationResult.Success("User 2", claims), _ => ApiKeyValidationResult.Fail("Invalid User") }; diff --git a/samples/MinimalApis/BasicAuthenticationSample/BasicAuthenticationSample.csproj b/samples/MinimalApis/BasicAuthenticationSample/BasicAuthenticationSample.csproj index b50f1e3..aa34a16 100644 --- a/samples/MinimalApis/BasicAuthenticationSample/BasicAuthenticationSample.csproj +++ b/samples/MinimalApis/BasicAuthenticationSample/BasicAuthenticationSample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/MinimalApis/JwtBearerSample/JwtBearerSample.csproj b/samples/MinimalApis/JwtBearerSample/JwtBearerSample.csproj index b50f1e3..aa34a16 100644 --- a/samples/MinimalApis/JwtBearerSample/JwtBearerSample.csproj +++ b/samples/MinimalApis/JwtBearerSample/JwtBearerSample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/samples/MinimalApis/Net8JwtBearerSample/Net8JwtBearerSample.csproj b/samples/MinimalApis/Net8JwtBearerSample/Net8JwtBearerSample.csproj index 17c6dda..f718c99 100644 --- a/samples/MinimalApis/Net8JwtBearerSample/Net8JwtBearerSample.csproj +++ b/samples/MinimalApis/Net8JwtBearerSample/Net8JwtBearerSample.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SimpleAuthentication.Swashbuckle/SimpleAuthentication.Swashbuckle.csproj b/src/SimpleAuthentication.Swashbuckle/SimpleAuthentication.Swashbuckle.csproj index c05a05e..cfe44cb 100644 --- a/src/SimpleAuthentication.Swashbuckle/SimpleAuthentication.Swashbuckle.csproj +++ b/src/SimpleAuthentication.Swashbuckle/SimpleAuthentication.Swashbuckle.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs b/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs index a3acb67..f3b0463 100644 --- a/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs +++ b/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +using System.Runtime.InteropServices; +using System.Security.Claims; +using System.Security.Cryptography; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; @@ -33,37 +35,39 @@ protected override async Task HandleAuthenticateAsync() var validator = serviceProvider.GetService() ?? throw new InvalidOperationException("There isn't a default value for API Key and no custom validator has been provided"); var validationResult = await validator.ValidateAsync(value.ToString()); - if (validationResult.Succeeded) + if (!validationResult.Succeeded) { - return CreateAuthenticationSuccessResult(validationResult.UserName, validationResult.Claims); + return AuthenticateResult.Fail(validationResult.FailureMessage); } - return AuthenticateResult.Fail(validationResult.FailureMessage); + return CreateAuthenticationSuccessResult(validationResult.UserName, validationResult.Claims); } - var apiKey = apiKeys.FirstOrDefault(c => c.Value == value); - if (apiKey is not null) + var apiKey = apiKeys.FirstOrDefault(a => CryptographicOperations.FixedTimeEquals(MemoryMarshal.AsBytes(a.Value.AsSpan()), MemoryMarshal.AsBytes(value.ToString().AsSpan()))); + + if (apiKey is null) + { + + return AuthenticateResult.Fail("Invalid API Key"); + } + + var claims = new List(); + if (apiKey.Roles is not null) { - var claims = new List(); - if (apiKey.Roles is not null) + foreach (var role in apiKey.Roles) { - foreach (var role in apiKey.Roles) - { - claims.Add(new(Options.RoleClaimType, role)); - } + claims.Add(new(Options.RoleClaimType, role)); } - - return CreateAuthenticationSuccessResult(apiKey.UserName, claims); } - return AuthenticateResult.Fail("Invalid API Key"); + return CreateAuthenticationSuccessResult(apiKey.UserName, claims); - AuthenticateResult CreateAuthenticationSuccessResult(string userName, IList? claims = null) + AuthenticateResult CreateAuthenticationSuccessResult(string userName, IEnumerable? claims = null) { - claims ??= []; - claims.Update(Options.NameClaimType, userName); + var claimsList = claims?.ToList() ?? []; + claimsList.Update(Options.NameClaimType, userName); - var identity = new ClaimsIdentity(claims, Scheme.Name, Options.NameClaimType, Options.RoleClaimType); + var identity = new ClaimsIdentity(claimsList, Scheme.Name, Options.NameClaimType, Options.RoleClaimType); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs b/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs index f8e54c0..0bc39a1 100644 --- a/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs +++ b/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +using System.Runtime.InteropServices; +using System.Security.Claims; +using System.Security.Cryptography; using System.Text; using System.Text.Encodings.Web; using System.Text.RegularExpressions; @@ -47,37 +49,39 @@ protected override async Task HandleAuthenticateAsync() var validator = serviceProvider.GetService() ?? throw new InvalidOperationException("There isn't a default user name and password for authentication and no custom validator has been provided"); var validationResult = await validator.ValidateAsync(userName, password); - if (validationResult.Succeeded) + if (!validationResult.Succeeded) { - return CreateAuthenticationSuccessResult(validationResult.UserName, validationResult.Claims); + return AuthenticateResult.Fail(validationResult.FailureMessage); } - return AuthenticateResult.Fail(validationResult.FailureMessage); + return CreateAuthenticationSuccessResult(validationResult.UserName, validationResult.Claims); } - var credential = credentials.FirstOrDefault(c => c.UserName == userName && c.Password == password); - if (credential is not null) + var credential = credentials.FirstOrDefault(c => CryptographicOperations.FixedTimeEquals(MemoryMarshal.AsBytes(c.UserName.AsSpan()), MemoryMarshal.AsBytes(userName.AsSpan())) + && CryptographicOperations.FixedTimeEquals(MemoryMarshal.AsBytes(c.Password.AsSpan()), MemoryMarshal.AsBytes(password.AsSpan()))); + + if (credential is null) + { + return AuthenticateResult.Fail("Invalid user name or password"); + } + + var claims = new List(); + if (credential.Roles is not null) { - var claims = new List(); - if (credential.Roles is not null) + foreach (var role in credential.Roles) { - foreach (var role in credential.Roles) - { - claims.Add(new(Options.RoleClaimType, role)); - } + claims.Add(new(Options.RoleClaimType, role)); } - - return CreateAuthenticationSuccessResult(credential.UserName, claims); } - return AuthenticateResult.Fail("Invalid user name or password"); + return CreateAuthenticationSuccessResult(credential.UserName, claims); - AuthenticateResult CreateAuthenticationSuccessResult(string userName, IList? claims = null) + AuthenticateResult CreateAuthenticationSuccessResult(string userName, IEnumerable? claims = null) { - claims ??= []; - claims.Update(Options.NameClaimType, userName); + var claimsList = claims?.ToList() ?? []; + claimsList.Update(Options.NameClaimType, userName); - var identity = new ClaimsIdentity(claims, Scheme.Name, Options.NameClaimType, Options.RoleClaimType); + var identity = new ClaimsIdentity(claimsList, Scheme.Name, Options.NameClaimType, Options.RoleClaimType); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); @@ -86,6 +90,6 @@ AuthenticateResult CreateAuthenticationSuccessResult(string userName, IList jwtBearerSettingsOptio protected JwtBearerSettings JwtBearerSettings { get; } = jwtBearerSettingsOptions?.Value ?? throw new ArgumentNullException(nameof(jwtBearerSettingsOptions)); /// - public virtual Task CreateTokenAsync(string userName, IList? claims = null, string? issuer = null, string? audience = null, DateTime? absoluteExpiration = null) + public virtual Task CreateTokenAsync(string userName, IEnumerable? claims = null, string? issuer = null, string? audience = null, DateTime? absoluteExpiration = null) { var now = DateTime.UtcNow; @@ -27,13 +27,13 @@ public virtual Task CreateTokenAsync(string userName, IList? clai throw new ArgumentException("The expiration date must be greater than or equal to the current date and time.", nameof(absoluteExpiration)); } - claims ??= []; - claims.Update(JwtBearerSettings.NameClaimType, userName); - claims.Update(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()); + var claimsList = claims?.ToList() ?? []; + claimsList.Update(JwtBearerSettings.NameClaimType, userName); + claimsList.Update(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()); var securityTokenDescriptor = new SecurityTokenDescriptor() { - Subject = new ClaimsIdentity(claims, JwtBearerSettings.SchemeName, JwtBearerSettings.NameClaimType, JwtBearerSettings.RoleClaimType), + Subject = new ClaimsIdentity(claimsList, JwtBearerSettings.SchemeName, JwtBearerSettings.NameClaimType, JwtBearerSettings.RoleClaimType), Issuer = issuer ?? JwtBearerSettings.Issuers?.FirstOrDefault(), Audience = audience ?? JwtBearerSettings.Audiences?.FirstOrDefault(), IssuedAt = now, @@ -63,9 +63,9 @@ public virtual async Task ValidateTokenAsync(string token, bool AuthenticationType = JwtBearerSettings.SchemeName, NameClaimType = JwtBearerSettings.NameClaimType, RoleClaimType = JwtBearerSettings.RoleClaimType, - ValidateIssuer = JwtBearerSettings.Issuers?.Any() ?? false, + ValidateIssuer = JwtBearerSettings.Issuers?.Length > 0, ValidIssuers = JwtBearerSettings.Issuers, - ValidateAudience = JwtBearerSettings.Audiences?.Any() ?? false, + ValidateAudience = JwtBearerSettings.Audiences?.Length > 0, ValidAudiences = JwtBearerSettings.Audiences, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtBearerSettings.SecurityKey)), @@ -89,7 +89,7 @@ public virtual async Task ValidateTokenAsync(string token, bool public virtual async Task RefreshTokenAsync(string token, bool validateLifetime, DateTime? absoluteExpiration = null) { var principal = await ValidateTokenAsync(token, validateLifetime); - var claims = (principal.Identity as ClaimsIdentity)!.Claims.ToList(); + var claims = (principal.Identity as ClaimsIdentity)!.Claims; var userName = claims.First(c => c.Type == JwtBearerSettings.NameClaimType).Value; var issuer = claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Iss)?.Value; diff --git a/src/SimpleAuthentication/SimpleAuthentication.csproj b/src/SimpleAuthentication/SimpleAuthentication.csproj index aef11b0..413c0c8 100644 --- a/src/SimpleAuthentication/SimpleAuthentication.csproj +++ b/src/SimpleAuthentication/SimpleAuthentication.csproj @@ -31,15 +31,15 @@ - + - + - + From c745c5202b35035df332fa3cc5d0cc1430329bd1 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Thu, 11 Jun 2026 10:25:51 +0200 Subject: [PATCH 2/3] Refactor API key comparison for clarity Assign value.ToString() to a variable before comparing API keys, improving readability and preventing multiple ToString() calls during fixed-time equality checks. --- src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs b/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs index f3b0463..4a79bc2 100644 --- a/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs +++ b/src/SimpleAuthentication/ApiKey/ApiKeyAuthenticationHandler.cs @@ -43,7 +43,8 @@ protected override async Task HandleAuthenticateAsync() return CreateAuthenticationSuccessResult(validationResult.UserName, validationResult.Claims); } - var apiKey = apiKeys.FirstOrDefault(a => CryptographicOperations.FixedTimeEquals(MemoryMarshal.AsBytes(a.Value.AsSpan()), MemoryMarshal.AsBytes(value.ToString().AsSpan()))); + var providedApiKey = value.ToString(); + var apiKey = apiKeys.FirstOrDefault(a => CryptographicOperations.FixedTimeEquals(MemoryMarshal.AsBytes(a.Value.AsSpan()), MemoryMarshal.AsBytes(providedApiKey.AsSpan()))); if (apiKey is null) { From 6a9773b2625dbab7fe610b5bcaa7761132446910 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Thu, 11 Jun 2026 10:32:53 +0200 Subject: [PATCH 3/3] Update Basic auth regex to use CultureInvariant option Added RegexOptions.CultureInvariant to the [GeneratedRegex] attribute for BasicAuthorizationHeaderRegex. This change ensures consistent parsing of the Basic authentication header regardless of the current culture settings. --- .../BasicAuthentication/BasicAuthenticationHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs b/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs index 0bc39a1..ef377be 100644 --- a/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs +++ b/src/SimpleAuthentication/BasicAuthentication/BasicAuthenticationHandler.cs @@ -90,6 +90,6 @@ AuthenticateResult CreateAuthenticationSuccessResult(string userName, IEnumerabl } } - [GeneratedRegex(@"Basic (.*)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + [GeneratedRegex(@"Basic (.*)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant)] private static partial Regex BasicAuthorizationHeaderRegex(); }