From 61f39653a0681180cb96b8f2238ddb542c0772fd Mon Sep 17 00:00:00 2001 From: ovska Date: Sat, 25 Jun 2022 15:08:25 +0300 Subject: [PATCH 1/8] reduce allocations in TokenRetrieval --- src/Infrastructure/TokenRetrieval.cs | 35 +++++++++++-------- test/Tests/Unit.cs | 51 ++++++++++++++++++++++++++++ test/Tests/Util/MockHttpRequest.cs | 34 +++++++++++++++++++ 3 files changed, 106 insertions(+), 14 deletions(-) create mode 100644 test/Tests/Unit.cs create mode 100644 test/Tests/Util/MockHttpRequest.cs diff --git a/src/Infrastructure/TokenRetrieval.cs b/src/Infrastructure/TokenRetrieval.cs index c3b07df..1b680b9 100644 --- a/src/Infrastructure/TokenRetrieval.cs +++ b/src/Infrastructure/TokenRetrieval.cs @@ -2,8 +2,8 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using System; -using System.Linq; namespace IdentityModel.AspNetCore.OAuth2Introspection { @@ -13,24 +13,26 @@ namespace IdentityModel.AspNetCore.OAuth2Introspection public static class TokenRetrieval { /// - /// Reads the token from the authrorization header. + /// Reads the token from the authorization header. /// /// The scheme (defaults to Bearer). - /// - public static Func FromAuthorizationHeader(string scheme = "Bearer") + public static Func FromAuthorizationHeader( + string scheme = OAuth2IntrospectionDefaults.AuthenticationScheme) { + string schemePrefix = scheme + " "; + return request => { - string authorization = request.Headers["Authorization"].FirstOrDefault(); - - if (string.IsNullOrEmpty(authorization)) + if (request.Headers.TryGetValue(HeaderNames.Authorization, out var value) && + value.Count != 0) { - return null; - } + string authorization = value[0]; - if (authorization.StartsWith(scheme + " ", StringComparison.OrdinalIgnoreCase)) - { - return authorization.Substring(scheme.Length + 1).Trim(); + if (!string.IsNullOrEmpty(authorization) && + authorization.StartsWith(schemePrefix, StringComparison.OrdinalIgnoreCase)) + { + return new string(authorization.AsSpan(schemePrefix.Length).Trim()); + } } return null; @@ -41,10 +43,15 @@ public static Func FromAuthorizationHeader(string scheme = /// Reads the token from a query string parameter. /// /// The name (defaults to access_token). - /// public static Func FromQueryString(string name = "access_token") { - return request => request.Query[name].FirstOrDefault(); + return request => + { + if (request.Query.TryGetValue(name, out var value) && value.Count > 0) + return value[0]; + + return null; + }; } } } \ No newline at end of file diff --git a/test/Tests/Unit.cs b/test/Tests/Unit.cs new file mode 100644 index 0000000..a7e8532 --- /dev/null +++ b/test/Tests/Unit.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using System.Text.Json; +using IdentityModel.AspNetCore.OAuth2Introspection; +using IdentityModel.AspNetCore.OAuth2Introspection.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Tests.Util; +using Xunit; + +namespace Tests +{ + public static class Unit + { + [Theory] + [InlineData(null, new string[] { })] + [InlineData(null, new string[] { "Basic XYZ" })] + [InlineData(null, new string[] { "Basic XYZ", "Bearer ABC" })] + [InlineData("ABC", new string[] { "Bearer ABC" })] + [InlineData("ABC", new string[] { "Bearer ABC " })] + [InlineData("ABC", new string[] { "Bearer ABC", "Basic XYZ" })] + [InlineData("ABC", new string[] { "Bearer ABC", "Bearer DEF" })] + [InlineData("ABC", new string[] { "Bearer ABC", "Bearer DEF" })] + [InlineData("ABC", new string[] { "Bearer ABC ", "Bearer DEF" })] + public static void Token_From_Header(string expected, string[] headerValues) + { + var request = new MockHttpRequest(); + request.Headers.Add("Authorization", new Microsoft.Extensions.Primitives.StringValues(headerValues)); + + var actual = TokenRetrieval.FromAuthorizationHeader()(request); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(null, "?a=1")] + [InlineData("", "?access_token=")] + [InlineData("", "?access_token&access_token")] + [InlineData("xyz", "?access_token=xyz")] + [InlineData("xyz", "?a=1&access_token=xyz")] + [InlineData("abc", "?access_token=abc&access_token=xyz")] + public static void Token_From_Query(string expected, string queryString) + { + var request = new MockHttpRequest + { + Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)) + }; + + var actual = TokenRetrieval.FromQueryString()(request); + Assert.Equal(expected, actual); + } + } +} diff --git a/test/Tests/Util/MockHttpRequest.cs b/test/Tests/Util/MockHttpRequest.cs new file mode 100644 index 0000000..ee36445 --- /dev/null +++ b/test/Tests/Util/MockHttpRequest.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Tests.Util +{ + internal class MockHttpRequest : HttpRequest + { + public override Stream Body { get; set; } + public override long? ContentLength { get; set; } + public override string ContentType { get; set; } + public override IRequestCookieCollection Cookies { get; set; } + public override IFormCollection Form { get; set; } + public override bool HasFormContentType { get; } + public override IHeaderDictionary Headers { get; } = new HeaderDictionary(); + public override HostString Host { get; set; } + public override HttpContext HttpContext { get; } + public override bool IsHttps { get; set; } + public override string Method { get; set; } + public override PathString Path { get; set; } + public override PathString PathBase { get; set; } + public override string Protocol { get; set; } + public override IQueryCollection Query { get; set; } + public override QueryString QueryString { get; set; } + public override string Scheme { get; set; } + + public override Task ReadFormAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } +} From f3cab23cfef66f8dfc82de712a53437099642ab9 Mon Sep 17 00:00:00 2001 From: ovska Date: Wed, 29 Jun 2022 23:41:47 +0300 Subject: [PATCH 2/8] Simplify jsonoptions creation in CacheExtensions --- src/Infrastructure/CacheExtensions.cs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Infrastructure/CacheExtensions.cs b/src/Infrastructure/CacheExtensions.cs index 8a217e0..51a92e8 100644 --- a/src/Infrastructure/CacheExtensions.cs +++ b/src/Infrastructure/CacheExtensions.cs @@ -9,7 +9,6 @@ using System.Security.Claims; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; namespace IdentityModel.AspNetCore.OAuth2Introspection @@ -20,24 +19,17 @@ internal static class CacheExtensions static CacheExtensions() { - -#if NET6_0_OR_GREATER Options = new JsonSerializerOptions { IgnoreReadOnlyFields = true, IgnoreReadOnlyProperties = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + Converters = { new ClaimConverter() }, +#if NET6_0_OR_GREATER + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, #else - Options = new JsonSerializerOptions - { - IgnoreReadOnlyFields = true, - IgnoreReadOnlyProperties = true, - IgnoreNullValues = true - }; + IgnoreNullValues = true, #endif - - Options.Converters.Add(new ClaimConverter()); + }; } public static async Task> GetClaimsAsync(this IDistributedCache cache, OAuth2IntrospectionOptions options, string token) From 40f85924d397f7506c4d1bc053b8a39c4c391138 Mon Sep 17 00:00:00 2001 From: ovska Date: Wed, 29 Jun 2022 23:49:24 +0300 Subject: [PATCH 3/8] Use LoggerMessage.Define for logging --- src/Infrastructure/CacheExtensions.cs | 7 ++--- src/Log.cs | 44 +++++++++++++++++++++++++++ src/OAuth2IntrospectionHandler.cs | 8 ++--- 3 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 src/Log.cs diff --git a/src/Infrastructure/CacheExtensions.cs b/src/Infrastructure/CacheExtensions.cs index 51a92e8..d1ccf31 100644 --- a/src/Infrastructure/CacheExtensions.cs +++ b/src/Infrastructure/CacheExtensions.cs @@ -51,14 +51,13 @@ public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2Intr var expClaim = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Expiration); if (expClaim == null) { - logger.LogWarning("No exp claim found on introspection response, can't cache."); + Log.NoExpClaimFound(logger, null); return; } var now = DateTimeOffset.UtcNow; var expiration = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expClaim.Value)); - logger.LogDebug("Token will expire in {expiration}", expiration); - + Log.TokenExpiresOn(logger, expiration, null); if (expiration <= now) { @@ -79,7 +78,7 @@ public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2Intr var json = JsonSerializer.Serialize(claims, Options); var bytes = Encoding.UTF8.GetBytes(json); - logger.LogDebug("Setting cache item expiration to {expiration}", absoluteLifetime); + Log.SettingToCache(logger, absoluteLifetime, null); var cacheKey = options.CacheKeyGenerator(options, token); await cache.SetAsync(cacheKey, bytes, new DistributedCacheEntryOptions { AbsoluteExpiration = absoluteLifetime }).ConfigureAwait(false); } diff --git a/src/Log.cs b/src/Log.cs new file mode 100644 index 0000000..7737b8a --- /dev/null +++ b/src/Log.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace IdentityModel.AspNetCore.OAuth2Introspection +{ + internal static class Log + { + public static readonly Action NoExpClaimFound + = LoggerMessage.Define( + LogLevel.Warning, + 1, + "No exp claim found on introspection response, can't cache"); + + public static readonly Action TokenExpiresOn + = LoggerMessage.Define( + LogLevel.Debug, + 2, + "Token will expire on {Expiration}"); + + public static readonly Action SettingToCache + = LoggerMessage.Define( + LogLevel.Debug, + 3, + "Setting cache item expiration to {Expiration}"); + + public static readonly Action SkippingDotToken + = LoggerMessage.Define( + LogLevel.Trace, + 4, + "Token contains a dot - skipped because SkipTokensWithDots is set"); + + public static readonly Action TokenNotCached + = LoggerMessage.Define( + LogLevel.Trace, + 5, + "Token is not cached"); + + public static readonly Action IntrospectionError + = LoggerMessage.Define( + LogLevel.Error, + 6, + "Error returned from introspection endpoint: {Error}"); + } +} diff --git a/src/OAuth2IntrospectionHandler.cs b/src/OAuth2IntrospectionHandler.cs index e962fa6..aad6370 100644 --- a/src/OAuth2IntrospectionHandler.cs +++ b/src/OAuth2IntrospectionHandler.cs @@ -78,9 +78,9 @@ protected override async Task HandleAuthenticateAsync() // if token contains a dot - it might be a JWT and we are skipping // this is configurable - if (token.Contains('.') && Options.SkipTokensWithDots) + if (Options.SkipTokensWithDots && token.Contains('.')) { - _logger.LogTrace("Token contains a dot - skipped because SkipTokensWithDots is set."); + Log.SkippingDotToken(_logger, null); return AuthenticateResult.NoResult(); } @@ -100,7 +100,7 @@ protected override async Task HandleAuthenticateAsync() return await CreateTicket(claims, token, Context, Scheme, Events, Options); } - _logger.LogTrace("Token is not cached."); + Log.TokenNotCached(_logger, null); } // no cached result - let's make a network roundtrip to the introspection endpoint @@ -119,7 +119,7 @@ Lazy> GetTokenIntrospectionResponseLazy(string if (response.IsError) { - _logger.LogError("Error returned from introspection endpoint: " + response.Error); + Log.IntrospectionError(_logger, response.Error, null); return await ReportNonSuccessAndReturn("Error returned from introspection endpoint: " + response.Error, Context, Scheme, Events, Options); } From 9e969a1b73ff956b1350f275076c9195ce11a0fc Mon Sep 17 00:00:00 2001 From: ovska Date: Wed, 29 Jun 2022 23:51:21 +0300 Subject: [PATCH 4/8] Deserialize cached claims directly from bytes --- src/Infrastructure/CacheExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Infrastructure/CacheExtensions.cs b/src/Infrastructure/CacheExtensions.cs index d1ccf31..420a729 100644 --- a/src/Infrastructure/CacheExtensions.cs +++ b/src/Infrastructure/CacheExtensions.cs @@ -42,8 +42,7 @@ public static async Task> GetClaimsAsync(this IDistributedCac return null; } - var json = Encoding.UTF8.GetString(bytes); - return JsonSerializer.Deserialize>(json, Options); + return JsonSerializer.Deserialize>(bytes, Options); } public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2IntrospectionOptions options, string token, IEnumerable claims, TimeSpan duration, ILogger logger) From e8307d79dd4fef44065d9860e92d27988126f8e5 Mon Sep 17 00:00:00 2001 From: ovska Date: Wed, 29 Jun 2022 23:56:24 +0300 Subject: [PATCH 5/8] Serialize cache claims directly to bytes --- src/Infrastructure/CacheExtensions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Infrastructure/CacheExtensions.cs b/src/Infrastructure/CacheExtensions.cs index 420a729..e93b676 100644 --- a/src/Infrastructure/CacheExtensions.cs +++ b/src/Infrastructure/CacheExtensions.cs @@ -74,8 +74,7 @@ public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2Intr absoluteLifetime = now.Add(duration); } - var json = JsonSerializer.Serialize(claims, Options); - var bytes = Encoding.UTF8.GetBytes(json); + var bytes = JsonSerializer.SerializeToUtf8Bytes(claims, Options); Log.SettingToCache(logger, absoluteLifetime, null); var cacheKey = options.CacheKeyGenerator(options, token); From fcf68720e0ab070069368b35b7456e0601f6e82b Mon Sep 17 00:00:00 2001 From: ovska Date: Thu, 14 Jul 2022 10:09:10 +0300 Subject: [PATCH 6/8] Calculate cache key only once in handler --- src/Infrastructure/CacheExtensions.cs | 7 +++---- src/OAuth2IntrospectionHandler.cs | 9 +++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Infrastructure/CacheExtensions.cs b/src/Infrastructure/CacheExtensions.cs index e93b676..14e0da6 100644 --- a/src/Infrastructure/CacheExtensions.cs +++ b/src/Infrastructure/CacheExtensions.cs @@ -32,9 +32,8 @@ static CacheExtensions() }; } - public static async Task> GetClaimsAsync(this IDistributedCache cache, OAuth2IntrospectionOptions options, string token) + public static async Task> GetClaimsAsync(this IDistributedCache cache, string cacheKey) { - var cacheKey = options.CacheKeyGenerator(options,token); var bytes = await cache.GetAsync(cacheKey).ConfigureAwait(false); if (bytes == null) @@ -45,9 +44,10 @@ public static async Task> GetClaimsAsync(this IDistributedCac return JsonSerializer.Deserialize>(bytes, Options); } - public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2IntrospectionOptions options, string token, IEnumerable claims, TimeSpan duration, ILogger logger) + public static async Task SetClaimsAsync(this IDistributedCache cache, string cacheKey, IEnumerable claims, TimeSpan duration, ILogger logger) { var expClaim = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Expiration); + if (expClaim == null) { Log.NoExpClaimFound(logger, null); @@ -77,7 +77,6 @@ public static async Task SetClaimsAsync(this IDistributedCache cache, OAuth2Intr var bytes = JsonSerializer.SerializeToUtf8Bytes(claims, Options); Log.SettingToCache(logger, absoluteLifetime, null); - var cacheKey = options.CacheKeyGenerator(options, token); await cache.SetAsync(cacheKey, bytes, new DistributedCacheEntryOptions { AbsoluteExpiration = absoluteLifetime }).ConfigureAwait(false); } } diff --git a/src/OAuth2IntrospectionHandler.cs b/src/OAuth2IntrospectionHandler.cs index aad6370..b9a8b58 100644 --- a/src/OAuth2IntrospectionHandler.cs +++ b/src/OAuth2IntrospectionHandler.cs @@ -84,10 +84,12 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.NoResult(); } + string cacheKey = Options.EnableCaching ? Options.CacheKeyGenerator(Options, token) : null; + // if caching is enable - let's check if we have a cached introspection if (Options.EnableCaching) { - var claims = await _cache.GetClaimsAsync(Options, token).ConfigureAwait(false); + var claims = await _cache.GetClaimsAsync(cacheKey).ConfigureAwait(false); if (claims != null) { // find out if it is a cached inactive token @@ -127,7 +129,7 @@ Lazy> GetTokenIntrospectionResponseLazy(string { if (Options.EnableCaching) { - await _cache.SetClaimsAsync(Options, token, response.Claims, Options.CacheDuration, _logger).ConfigureAwait(false); + await _cache.SetClaimsAsync(cacheKey, response.Claims, Options.CacheDuration, _logger).ConfigureAwait(false); } return await CreateTicket(response.Claims, token, Context, Scheme, Events, Options); @@ -140,8 +142,7 @@ Lazy> GetTokenIntrospectionResponseLazy(string var claimsWithExp = response.Claims.ToList(); claimsWithExp.Add(new Claim("exp", DateTimeOffset.UtcNow.Add(Options.CacheDuration).ToUnixTimeSeconds().ToString())); - await _cache.SetClaimsAsync(Options, token, claimsWithExp, Options.CacheDuration, _logger) - .ConfigureAwait(false); + await _cache.SetClaimsAsync(cacheKey, claimsWithExp, Options.CacheDuration, _logger).ConfigureAwait(false); } return await ReportNonSuccessAndReturn("Token is not active.", Context, Scheme, Events, Options); From 44ab2886d9953d07df373e32e9e0efc83150bad5 Mon Sep 17 00:00:00 2001 From: ovska Date: Thu, 14 Jul 2022 10:21:06 +0300 Subject: [PATCH 7/8] Simplify inactive claim check in handler --- src/OAuth2IntrospectionHandler.cs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/OAuth2IntrospectionHandler.cs b/src/OAuth2IntrospectionHandler.cs index b9a8b58..be975d9 100644 --- a/src/OAuth2IntrospectionHandler.cs +++ b/src/OAuth2IntrospectionHandler.cs @@ -50,7 +50,7 @@ public OAuth2IntrospectionHandler( /// - /// The handler calls methods on the events which give the application control at certain points where processing is occurring. + /// The handler calls methods on the events which give the application control at certain points where processing is occurring. /// If it is not provided a default instance is supplied which does nothing when the methods are called. /// protected new OAuth2IntrospectionEvents Events @@ -93,8 +93,8 @@ protected override async Task HandleAuthenticateAsync() if (claims != null) { // find out if it is a cached inactive token - var isInActive = claims.FirstOrDefault(c => string.Equals(c.Type, "active", StringComparison.OrdinalIgnoreCase) && string.Equals(c.Value, "false", StringComparison.OrdinalIgnoreCase)); - if (isInActive != null) + var isInActive = claims.Any(c => string.Equals(c.Type, "active", StringComparison.OrdinalIgnoreCase) && string.Equals(c.Value, "false", StringComparison.OrdinalIgnoreCase)); + if (isInActive) { return await ReportNonSuccessAndReturn("Cached token is not active.", Context, Scheme, Events, Options); } @@ -155,10 +155,10 @@ Lazy> GetTokenIntrospectionResponseLazy(string } private static async Task ReportNonSuccessAndReturn( - string error, - HttpContext httpContext, - AuthenticationScheme scheme, - OAuth2IntrospectionEvents events, + string error, + HttpContext httpContext, + AuthenticationScheme scheme, + OAuth2IntrospectionEvents events, OAuth2IntrospectionOptions options) { var authenticationFailedContext = new AuthenticationFailedContext(httpContext, scheme, options) @@ -172,10 +172,10 @@ private static async Task ReportNonSuccessAndReturn( } private static async Task LoadClaimsForToken( - string token, - HttpContext context, - AuthenticationScheme scheme, - OAuth2IntrospectionEvents events, + string token, + HttpContext context, + AuthenticationScheme scheme, + OAuth2IntrospectionEvents events, OAuth2IntrospectionOptions options) { var introspectionClient = await options.IntrospectionClient.Value.ConfigureAwait(false); @@ -232,10 +232,10 @@ private static TokenIntrospectionRequest CreateTokenIntrospectionRequest( } private static async Task CreateTicket( - IEnumerable claims, - string token, - HttpContext httpContext, - AuthenticationScheme scheme, + IEnumerable claims, + string token, + HttpContext httpContext, + AuthenticationScheme scheme, OAuth2IntrospectionEvents events, OAuth2IntrospectionOptions options) { From 8faaefdbd645c2974035b4d8e7b2731e3318bb1b Mon Sep 17 00:00:00 2001 From: ovska Date: Thu, 14 Jul 2022 12:41:56 +0300 Subject: [PATCH 8/8] Improve default cachekey creation perf on net6+ --- src/Infrastructure/CacheUtils.cs | 2 +- src/Infrastructure/StringExtensions.cs | 65 +++++++++++++++++++++++--- test/Tests/Unit.cs | 29 ++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/Infrastructure/CacheUtils.cs b/src/Infrastructure/CacheUtils.cs index 2bcfff3..589c4bc 100644 --- a/src/Infrastructure/CacheUtils.cs +++ b/src/Infrastructure/CacheUtils.cs @@ -18,7 +18,7 @@ public static class CacheUtils /// public static Func CacheKeyFromToken() { - return (options, token) => $"{options.CacheKeyPrefix}{token.Sha256()}"; + return (options, token) => token.Sha256(options.CacheKeyPrefix); } } } diff --git a/src/Infrastructure/StringExtensions.cs b/src/Infrastructure/StringExtensions.cs index 055a563..5907a9d 100644 --- a/src/Infrastructure/StringExtensions.cs +++ b/src/Infrastructure/StringExtensions.cs @@ -5,6 +5,9 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; +#if NET6_0_OR_GREATER +using System.Buffers; +#endif namespace IdentityModel.AspNetCore.OAuth2Introspection { @@ -33,17 +36,67 @@ public static bool IsPresent(this string value) return !string.IsNullOrWhiteSpace(value); } - internal static string Sha256(this string input) + /// + /// Returns Base64 UTF8 bytes of appended to . + /// If is missing, returns only prefix. + /// + internal static string Sha256(this string input, string prefix) { - if (input.IsMissing()) return string.Empty; + if (input.IsMissing()) return prefix; - using (var sha = SHA256.Create()) +#if !NET6_0_OR_GREATER + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(input); + var hash = sha.ComputeHash(bytes); + return prefix + Convert.ToBase64String(hash); +#else + const int Base64Sha256Len = 44; // base64 sha256 is always 44 chars + return string.Create(prefix.Length + Base64Sha256Len, (input, prefix), _sha256WithPrefix); +#endif + } + +#if NET6_0_OR_GREATER + private static readonly SpanAction _sha256WithPrefix = Sha256WithPrefix; + + /// + /// Writes prefix with input's sha256 hash as base64 appended to the span. + /// + private static void Sha256WithPrefix(Span destination, (string input, string prefix) state) + { + const int Sha256Len = 32; // sha256 is always 32 bytes + const int MaxStackAlloc = 256; + + var (input, prefix) = state; + + // use a rented buffer if input as bytes would be dangerously long to stackalloc + byte[] rented = null; + + try { - var bytes = Encoding.UTF8.GetBytes(input); - var hash = sha.ComputeHash(bytes); + int maxUtf8Len = Encoding.UTF8.GetMaxByteCount(input.Length); - return Convert.ToBase64String(hash); + Span utf8buffer = maxUtf8Len > MaxStackAlloc + ? (rented = ArrayPool.Shared.Rent(maxUtf8Len)) + : stackalloc byte[maxUtf8Len]; + + int utf8Written = Encoding.UTF8.GetBytes(input, utf8buffer); + + Span hashBuffer = stackalloc byte[Sha256Len]; + int hashedCount = SHA256.HashData(utf8buffer[..utf8Written], hashBuffer); + Debug.Assert(hashedCount == Sha256Len); + + if (prefix.Length != 0) + prefix.CopyTo(destination); + + bool b64success = Convert.TryToBase64Chars(hashBuffer, destination[prefix.Length..], out var b64written); + Debug.Assert(b64success); + } + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); } } +#endif } } \ No newline at end of file diff --git a/test/Tests/Unit.cs b/test/Tests/Unit.cs index a7e8532..afd2128 100644 --- a/test/Tests/Unit.cs +++ b/test/Tests/Unit.cs @@ -11,6 +11,35 @@ namespace Tests { public static class Unit { + [Theory] + [InlineData(null, "key:")] + [InlineData("", "key:")] + [InlineData(" ", "key:")] + [InlineData("abcdefg01234", "key:9/+6X7C6m2lsSY7l+QUPZ8WP88j03/JP3iTSUUFqJBY=")] + [InlineData("0123456789012345678901234567890123456789", "key:+1Js1K0OyXjBqeePfAcocRE5l4Qk1hjrIovlniEYiXA=")] + public static void CacheKey_From_Token(string input, string expected) + { + var opts = new OAuth2IntrospectionOptions { CacheKeyPrefix = "key:" }; + var key = CacheUtils.CacheKeyFromToken()(opts, input); + Assert.Equal(expected, key); + } + + [Fact] + public static void CacheKey_From_Long_Token() + { + const string token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4g" + + "RG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5ceyJhbGc" + + "iOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiw" + + "iaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5ceyJhbGciOiJIUz" + + "I1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0I" + + "joxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + var opts = new OAuth2IntrospectionOptions { CacheKeyPrefix = "" }; + var key = CacheUtils.CacheKeyFromToken()(opts, token); + Assert.Equal("isDF1Tx4u6Fm+T7JQ2gK3yUimvGzy7jF1e7X79vDVTs=", key); + } + [Theory] [InlineData(null, new string[] { })] [InlineData(null, new string[] { "Basic XYZ" })]