Skip to content

Commit

Permalink
Add case insensitivity to header and query keys (#15)
Browse files Browse the repository at this point in the history
* Adding case in-/sensitivity option to header and query keys

* Bumped minor version (no breaking changes, but new feature)
  • Loading branch information
dnmh-psc committed Jul 13, 2023
1 parent 140b83a commit a1fce19
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 6 deletions.
8 changes: 4 additions & 4 deletions Source/AuthenticationHandler/ApiKeyAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
/// <summary>
/// The default value for <see cref="QueryKeys"/> if none are added.
/// </summary>
public const string DefaultQueryKey = "apikey";
public static readonly Key DefaultQueryKey = new("apikey", true);

/// <summary>
/// The default value for <see cref="HeaderKeys"/> if none are added.
/// </summary>
public const string DefaultHeaderKey = "X-API-KEY";
public static readonly Key DefaultHeaderKey = new("X-API-KEY", true);

/// <inheritdoc/>
public new ApiKeyAuthenticationEvents? Events
Expand Down Expand Up @@ -49,7 +49,7 @@ public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
/// Example of query:
/// <code>&lt;QueryKey&gt;=abcdef12345</code>
/// </example>
public ISet<string> QueryKeys { get; set; } = new HashSet<string>();
public ISet<Key> QueryKeys { get; set; } = new HashSet<Key>();

/// <summary>
/// Get or set the allowed keys used for the api key, when provided as a request header parameter.
Expand All @@ -62,7 +62,7 @@ public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
/// Example of header, if the <see cref="HeaderKeys"/> contains <c>X-API-KEY</c>:
/// <code>X-API-KEY: abcdef12345</code>
/// </example>
public ISet<string> HeaderKeys { get; set; } = new HashSet<string>();
public ISet<Key> HeaderKeys { get; set; } = new HashSet<Key>();

/// <summary>
/// If <c>true</c>, then the standard <c>Authorization</c> header key is used in combination with <see cref="AuthorizationSchemeInHeader"/>.
Expand Down
35 changes: 35 additions & 0 deletions Source/AuthenticationHandler/Internal/KeyExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler.Internal;

/// <summary>
/// Extension methods for <see cref="Key"/>
/// </summary>
internal static class KeyExtensions
{
/// <summary>
/// Intersects an <see cref="IEnumerable{T}"/> with type <see cref="Key"/> and an <see cref="IEnumerable{T}"/> with type <see cref="string"/> using each key's <see cref="Key.StringComparer"/>
/// </summary>
public static IEnumerable<Key> Intersect(this IEnumerable<Key> first, IEnumerable<string> second)
{
ArgumentNullException.ThrowIfNull(first, nameof(first));
ArgumentNullException.ThrowIfNull(second, nameof(second));

return first.Where(x => second.Contains(x.Name, x.StringComparer));
}

/// <summary>
/// Determines if an <see cref="IEnumerable{T}"/> with type <see cref="Key"/> contains the given <paramref name="value"/> using each key's <see cref="Key.StringComparer"/>
/// </summary>
public static bool Contains(this IEnumerable<Key> keys, string value)
{
ArgumentNullException.ThrowIfNull(keys, nameof(keys));

foreach (var key in keys)
{
if (key.StringComparer.Equals(key.Name, value))
{
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public PostConfigureSwaggerAuthorization(string authenticationScheme, IOptionsMo
public void PostConfigure(string? name, SwaggerGenOptions options)
{
var parameterLocation = _authenticationOptions.AllowApiKeyInQuery ? ParameterLocation.Query : ParameterLocation.Header;
var keyName = parameterLocation == ParameterLocation.Query ? _authenticationOptions.QueryKeys.First() : _authenticationOptions.HeaderKeys.First();
var keyName = parameterLocation == ParameterLocation.Query ? _authenticationOptions.QueryKeys.First().Name : _authenticationOptions.HeaderKeys.First().Name;
var scheme = _authenticationOptions.UseSchemeNameInAuthorizationHeader ? _swaggerSchemeOptions.AuthenticationScheme : _authenticationOptions.AuthorizationSchemeInHeader;
// Setup the security definition
options.AddSecurityDefinition(keyName, new OpenApiSecurityScheme
Expand Down
91 changes: 91 additions & 0 deletions Source/AuthenticationHandler/Key.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler;

/// <summary>
/// A key with a string value and a case in-/sensitive boolean value
/// </summary>
public class Key : IEquatable<Key>
{
/// <summary>
/// The name of the key
/// </summary>
public string Name { get; }

/// <summary>
/// Indicates if the key is case sensitive
/// </summary>
public bool IsCaseSensitive { get; }

/// <summary>
/// <see cref="System.StringComparer"/> based on <see cref="IsCaseSensitive"/>
/// </summary>
public StringComparer StringComparer { get; }

/// <summary>
/// Creates a new instance
/// </summary>
/// <param name="name">The name of the key</param>
/// <param name="isCaseSensitive">Indicates wether or not the key is case sensitive or not</param>
public Key(string name, bool isCaseSensitive)
{
ArgumentNullException.ThrowIfNull(name, nameof(name));

Name = name;
IsCaseSensitive = isCaseSensitive;
StringComparer = StringComparer.FromComparison(IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
}

/// <inheritdoc/>
public override bool Equals(object? obj) => Equals(obj as Key);

/// <inheritdoc/>
public bool Equals(Key? other)
{
if (other is null)
{
return false;
}

// Optimization for a common success case.
if (ReferenceEquals(this, other))
{
return true;
}

// If run-time types are not exactly the same, return false.
if (GetType() != other.GetType())
{
return false;
}

// Return true if the fields match.
// If either of the StringComparers return true, then they are equal (one might be case sensitive, and the other not - the non-case sensitive then overrules)
return StringComparer.Equals(Name, other.Name) || other.StringComparer.Equals(Name, other.Name);
}

/// <inheritdoc/>
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Name); // using case-insensitive HashCode here, in order to trigger an equality test (using .Equals) on hash collisions.

/// <summary>
/// Tests equality
/// </summary>
public static bool operator ==(Key lhs, Key rhs)
{
if (lhs is null)
{
if (rhs is null)
{
return true;
}

// Only the left side is null.
return false;
}
// Equals handles case of null on right side.
return lhs.Equals(rhs);
}

/// <summary>
/// Tests inequality
/// </summary>
public static bool operator !=(Key lhs, Key rhs) => !(lhs == rhs);
}
13 changes: 13 additions & 0 deletions Source/AuthenticationHandler/KeyHashSetExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler;

/// <summary>
/// Extension methods for <see cref="ISet{Task}"/> with type <see cref="Key"/>
/// </summary>
public static class KeyHashSetExtensions
{
/// <summary>
/// Adds a new <see cref="Key"/> to the set
/// </summary>
public static bool Add(this ISet<Key> set, string value, bool isCaseSensitive = true) =>
set.Add(new Key(value, isCaseSensitive));
}
2 changes: 1 addition & 1 deletion Source/Dnmh.Security.ApiKeyAuthentication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/DetNordjyskeMediehus/Dnmh.Security.ApiKeyAuthentication</PackageProjectUrl>
<Title>.Net ApiKey Authentication</Title>
<Version>3.0.0</Version>
<Version>3.1.0</Version>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>A .NET Core library that provides API key authentication for your web applications. With this library, you can require API keys to access your API endpoints and secure your application against unauthorized access. The library can also be integrated with Swagger UI to provide a seamless authentication experience.</Description>
<PackageTags>authentication dotnet .Net dotnetcore .NetCore apikey apikey-authentication swagger swagger-ui</PackageTags>
Expand Down
65 changes: 65 additions & 0 deletions Tests/AuthenticationHandler/ApiKeyAuthenticationHandlerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ public async Task TestOptionsWithMultipleCustomHeaderKeysSuccessfully()
result.Succeeded.Should().BeTrue();
}

[Fact]
public async Task TestOptionsWithCaseInsensitiveCustomHeaderKeysSuccessfully()
{
// Arrange
var optionsMock = MockHelpers.CreateMockOptionsMonitor<ApiKeyAuthenticationOptions>(options =>
{
options.AllowApiKeyInQuery = false;
options.AllowApiKeyInRequestHeader = true;
options.HeaderKeys.Add("X-ApiKey", false);
});
var handler = MockHelpers.CreateApiKeyAuthenticationHandler(optionsMock.Object);
var mockHttpContext = MockHelpers.CreateMockHttpContextWithRequestHeaders(new Dictionary<string, StringValues> { { "x-apikey", "key" } });
await handler.InitializeWithSchemeNameAsync(mockHttpContext.Object);

// Act
var result = await handler.AuthenticateAsync();

// Assert
result.Should().NotBeNull();
result.Succeeded.Should().BeTrue();
}

[Fact]
public async Task TestOptionsWithMultipleCustomHeaderKeysButNoneMatchShouldFail()
{
Expand Down Expand Up @@ -276,4 +298,47 @@ public async Task TestOptionsWithMultipleCustomQueryKeysButNoneMatchShouldFail()
result.Should().NotBeNull();
result.Succeeded.Should().BeFalse();
}

[Fact]
public async Task TestOptionsWithCaseInsensitiveCustomQueryKeySuccessfully()
{
// Arrange
var optionsMock = MockHelpers.CreateMockOptionsMonitor<ApiKeyAuthenticationOptions>(options =>
{
options.QueryKeys.Add("mykey", false);
});
var handler = MockHelpers.CreateApiKeyAuthenticationHandler(optionsMock.Object);
var mockHttpContext = MockHelpers.CreateMockHttpContextWithRequestQueryParams(new Dictionary<string, StringValues> { { "MYKEY", "key" } });
await handler.InitializeWithSchemeNameAsync(mockHttpContext.Object);

// Act
var result = await handler.AuthenticateAsync();

// Assert
result.Should().NotBeNull();
result.Succeeded.Should().BeTrue();
}

[Fact]
public async Task TestOptionsWithMultipleCaseInsensitiveCustomQueryKeySuccessfully()
{
// Arrange
var optionsMock = MockHelpers.CreateMockOptionsMonitor<ApiKeyAuthenticationOptions>(options =>
{
options.QueryKeys.Add("mykey", false);
options.QueryKeys.Add("otherkey", true);
});
var handler = MockHelpers.CreateApiKeyAuthenticationHandler(optionsMock.Object);
var mockHttpContext = MockHelpers.CreateMockHttpContextWithRequestQueryParams(new Dictionary<string, StringValues> { { "MYKEY", "key" }, { "OTHERKEY", "key" } });
await handler.InitializeWithSchemeNameAsync(mockHttpContext.Object);

// Act
var result = await handler.AuthenticateAsync();

// Assert
result.Should().NotBeNull();
result.Succeeded.Should().BeTrue();
}


}
115 changes: 115 additions & 0 deletions Tests/AuthenticationHandler/KeyTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Dnmh.Security.ApiKeyAuthentication.AuthenticationHandler;
using FluentAssertions;

namespace Dnmh.Security.ApiKeyAuthentication.Tests.AuthenticationHandler;

public class KeyTest
{
[Fact]
public void KeyEqualityTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("key", true);

// Act & Assert
key1.Should().Be(key2);
}

[Fact]
public void KeyInequalityTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("KEY", true);

// Act & Assert
key1.Should().NotBe(key2);
}

[Fact]
public void KeyCaseInsensitiveEqualityTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("KEY", false);

// Act & Assert
key1.Should().Be(key2);
}

[Fact]
public void KeyCaseInsensitiveEqualityReverseTest()
{
// Arrange
var key1 = new Key("key", false);
var key2 = new Key("KEY", true);

// Act & Assert
key1.Should().Be(key2);
}

[Fact]
public void KeyHashSetAddSameKeyTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("key", true);

// Act
var set = new HashSet<Key>() { key1 };
var result = set.Add(key2);

// Assert
result.Should().BeFalse();
set.Should().ContainSingle();
}

[Fact]
public void KeyHashSetAddDifferentKeyTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("KEY", true);

// Act
var set = new HashSet<Key>() { key1 };
var result = set.Add(key2);

// Assert
result.Should().BeTrue();
set.Should().HaveCount(2);
}

[Fact]
public void KeyHashSetAddSameCaseInsensitiveKeyTest()
{
// Arrange
var key1 = new Key("key", true);
var key2 = new Key("KEY", false);

// Act
var set = new HashSet<Key>() { key1 };
var result = set.Add(key2);

// Assert
result.Should().BeFalse();
set.Should().ContainSingle();
}

[Fact]
public void KeyHashSetAddSameCaseInsensitiveKeyReverseTest()
{
// Arrange
var key1 = new Key("key", false);
var key2 = new Key("KEY", true);

// Act
var set = new HashSet<Key>() { key1 };
var result = set.Add(key2);

// Assert
result.Should().BeFalse();
set.Should().ContainSingle();
}
}

0 comments on commit a1fce19

Please sign in to comment.