From 64aec0603abf071a9c7dc22fb8d3f135908b9aaa Mon Sep 17 00:00:00 2001 From: Erwin van der Valk Date: Fri, 17 Apr 2026 10:36:34 +0200 Subject: [PATCH] make max token length configurable --- .../OpenIdConnectUserTokenEndpoint.cs | 13 +++--- .../StoreTokensInAuthenticationProperties.cs | 7 +++- .../UserTokenManagementOptions.cs | 7 ++++ .../src/AccessTokenManagement/AccessToken.cs | 33 ++++++++++++++- ...ClientCredentialsTokenManagementOptions.cs | 7 ++++ .../Internal/ClientCredentialsTokenClient.cs | 7 +++- .../src/AccessTokenManagement/RefreshToken.cs | 32 ++++++++++++++- .../ClientTokenManagementTests.cs | 24 +++++++++++ ...ficationTests.VerifyPublicApi.verified.txt | 11 +++-- ...VerifyPublicApi_OpenIdConnect.verified.txt | 3 +- ...reTokensInAuthenticationPropertiesTests.cs | 23 +++++++++++ .../UserTokenManagementTests.cs | 41 +++++++++++++++++++ 12 files changed, 192 insertions(+), 16 deletions(-) diff --git a/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/OpenIdConnectUserTokenEndpoint.cs b/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/OpenIdConnectUserTokenEndpoint.cs index 1496fc361..6ae8f06b4 100644 --- a/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/OpenIdConnectUserTokenEndpoint.cs +++ b/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/OpenIdConnectUserTokenEndpoint.cs @@ -23,6 +23,8 @@ internal class OpenIdConnectUserTokenEndpoint( IDPoPProofService dPoPProofService, ILogger logger) : IOpenIdConnectUserTokenEndpoint { + private readonly UserTokenManagementOptions _options = options.Value; + /// public async Task> RefreshAccessTokenAsync( UserRefreshToken refreshToken, @@ -47,7 +49,7 @@ public async Task> RefreshAccessTokenAsync( Address = tokenEndpoint.ToString(), ClientId = oidc.ClientId.ToString(), ClientSecret = oidc.ClientSecret.ToString(), - ClientCredentialStyle = options.Value.ClientCredentialStyle, + ClientCredentialStyle = _options.ClientCredentialStyle, RefreshToken = refreshToken.RefreshToken.ToString(), Parameters = parameters.Parameters }; @@ -156,8 +158,9 @@ public async Task> RefreshAccessTokenAsync( var token = new UserToken() { IdentityToken = IdentityToken.ParseOrDefault(response.IdentityToken), - AccessToken = AccessToken.Parse(response.AccessToken ?? - throw new InvalidOperationException("No access token present")), + AccessToken = AccessToken.Parse( + response.AccessToken ?? throw new InvalidOperationException("No access token present"), + _options.TokenMaxLength), AccessTokenType = AccessTokenType.ParseOrDefault(response.TokenType), DPoPJsonWebKey = dPoPJsonWebKey, Expiration = response.ExpiresIn == 0 @@ -165,7 +168,7 @@ public async Task> RefreshAccessTokenAsync( : DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn), RefreshToken = response.RefreshToken == null ? refreshToken.RefreshToken // use input refresh token if none is returned - : RefreshToken.Parse(response.RefreshToken), + : RefreshToken.Parse(response.RefreshToken, _options.TokenMaxLength), Scope = Scope.ParseOrDefault(response.Scope), ClientId = oidc.ClientId }; @@ -197,7 +200,7 @@ public async Task RevokeRefreshTokenAsync( ClientId = oidc.ClientId.ToString(), ClientSecret = oidc.ClientSecret.ToString(), - ClientCredentialStyle = options.Value.ClientCredentialStyle, + ClientCredentialStyle = _options.ClientCredentialStyle, Token = refreshToken.ToString(), TokenTypeHint = OidcConstants.TokenTypes.RefreshToken, diff --git a/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/StoreTokensInAuthenticationProperties.cs b/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/StoreTokensInAuthenticationProperties.cs index 9315b9632..14219d39e 100644 --- a/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/StoreTokensInAuthenticationProperties.cs +++ b/access-token-management/src/AccessTokenManagement.OpenIdConnect/Internal/StoreTokensInAuthenticationProperties.cs @@ -47,6 +47,7 @@ private static string NamePrefixAndResourceSuffix(string type, UserTokenRequestP /// public TokenResult GetUserToken(AuthenticationProperties authenticationProperties, UserTokenRequestParameters? parameters = null) { + var tokenMaxLength = tokenManagementOptionsMonitor.CurrentValue.TokenMaxLength; var tokens = authenticationProperties.Items.Where(i => i.Key.StartsWith(TokenPrefix)).ToList(); if (!tokens.Any()) { @@ -81,7 +82,7 @@ public TokenResult GetUserToken(AuthenticationProperties aut var refreshToken = refreshTokenValue == null ? null : new UserRefreshToken( - RefreshToken.Parse(refreshTokenValue), + RefreshToken.Parse(refreshTokenValue, tokenMaxLength), DPoPProofKey.ParseOrDefault(dpopKey)); if (accessTokenValue == null && refreshToken != null) @@ -91,7 +92,9 @@ public TokenResult GetUserToken(AuthenticationProperties aut var userToken = new UserToken { - AccessToken = AccessToken.Parse(accessTokenValue ?? throw new NullReferenceException("access_token should not be null here.")), + AccessToken = AccessToken.Parse( + accessTokenValue ?? throw new NullReferenceException("access_token should not be null here."), + tokenMaxLength), AccessTokenType = AccessTokenType.ParseOrDefault(accessTokenType), DPoPJsonWebKey = DPoPProofKey.ParseOrDefault(dpopKey), RefreshToken = refreshToken?.RefreshToken, diff --git a/access-token-management/src/AccessTokenManagement.OpenIdConnect/UserTokenManagementOptions.cs b/access-token-management/src/AccessTokenManagement.OpenIdConnect/UserTokenManagementOptions.cs index 4fd721d5c..b099da4bc 100644 --- a/access-token-management/src/AccessTokenManagement.OpenIdConnect/UserTokenManagementOptions.cs +++ b/access-token-management/src/AccessTokenManagement.OpenIdConnect/UserTokenManagementOptions.cs @@ -12,6 +12,13 @@ namespace Duende.AccessTokenManagement.OpenIdConnect; /// public sealed class UserTokenManagementOptions { + private const int Kilobyte = 1024; + + /// + /// Maximum allowed token length when reading tokens from token responses or authentication state. + /// + public int TokenMaxLength { get; set; } = 4 * Kilobyte; + /// /// Name of the authentication scheme to use for deriving token service configuration /// (will fall back to configured default challenge scheme if not set) diff --git a/access-token-management/src/AccessTokenManagement/AccessToken.cs b/access-token-management/src/AccessTokenManagement/AccessToken.cs index de34344e7..29ee2f4c7 100644 --- a/access-token-management/src/AccessTokenManagement/AccessToken.cs +++ b/access-token-management/src/AccessTokenManagement/AccessToken.cs @@ -13,15 +13,24 @@ namespace Duende.AccessTokenManagement; [JsonConverter(typeof(StringValueJsonConverter))] public readonly record struct AccessToken : IStronglyTypedValue { + private const int Kilobyte = 1024; + public override string ToString() => Value; - // Officially, there's no max length for JWTs, but 32k is a good limit - public const int MaxLength = 32 * 1024; // 32k + // Officially, there's no max length for JWTs, but keep construction bounded. + // Runtime read boundaries apply the configurable limit. + public const int MaxLength = 100 * Kilobyte; private static readonly ValidationRule[] Validators = [ ValidationRules.MaxLength(MaxLength) ]; + private static ValidationRule[] BuildValidators(int maxLength) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength); + return [ValidationRules.MaxLength(maxLength)]; + } + /// /// Convenience method for converting a into a string. /// @@ -44,10 +53,30 @@ namespace Duende.AccessTokenManagement; public static bool TryParse(string value, [NotNullWhen(true)] out AccessToken? parsed, out string[] errors) => IStronglyTypedValue.TryBuildValidatedObject(value, Validators, out parsed, out errors); + /// + /// Parses a value to a using the supplied maximum length. + /// + public static bool TryParse(string value, int maxLength, [NotNullWhen(true)] out AccessToken? parsed, out string[] errors) => + IStronglyTypedValue.TryBuildValidatedObject(value, BuildValidators(maxLength), out parsed, out errors); + static AccessToken IStronglyTypedValue.Create(string result) => new(result); /// /// Parses a value to a . This will throw an exception if the string is not valid. /// public static AccessToken Parse(string value) => StringParsers.Parse(value); + + /// + /// Parses a value to a using the supplied maximum length. + /// + public static AccessToken Parse(string value, int maxLength) + { + if (TryParse(value, maxLength, out var parsed, out var errors)) + { + return parsed.Value; + } + + throw new InvalidOperationException( + $"Received an invalid {nameof(AccessToken)}. Errors: {string.Join("", errors.Select(x => $"{Environment.NewLine}\t - {x}"))}"); + } } diff --git a/access-token-management/src/AccessTokenManagement/ClientCredentialsTokenManagementOptions.cs b/access-token-management/src/AccessTokenManagement/ClientCredentialsTokenManagementOptions.cs index 78f35c976..549ec1b82 100644 --- a/access-token-management/src/AccessTokenManagement/ClientCredentialsTokenManagementOptions.cs +++ b/access-token-management/src/AccessTokenManagement/ClientCredentialsTokenManagementOptions.cs @@ -8,6 +8,13 @@ namespace Duende.AccessTokenManagement; /// public sealed class ClientCredentialsTokenManagementOptions { + private const int Kilobyte = 1024; + + /// + /// Maximum allowed token length when reading tokens from external systems or caches. + /// + public int TokenMaxLength { get; set; } = 32 * Kilobyte; + /// /// Used to prefix the cache key /// diff --git a/access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenClient.cs b/access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenClient.cs index e243115df..105d02da9 100644 --- a/access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenClient.cs +++ b/access-token-management/src/AccessTokenManagement/Internal/ClientCredentialsTokenClient.cs @@ -16,12 +16,15 @@ internal class ClientCredentialsTokenClient( AccessTokenManagementMetrics metrics, IHttpClientFactory httpClientFactory, IOptionsMonitor options, + IOptions tokenManagementOptions, IClientAssertionService clientAssertionService, TimeProvider time, IDPoPKeyStore dPoPKeyMaterialService, IDPoPProofService dPoPProofService, ILogger logger) : IClientCredentialsTokenEndpoint { + private readonly ClientCredentialsTokenManagementOptions _tokenManagementOptions = tokenManagementOptions.Value; + /// public virtual async Task> RequestAccessTokenAsync( ClientCredentialsClientName clientName, @@ -158,7 +161,9 @@ public virtual async Task> RequestAccessToke var token = new ClientCredentialsToken { - AccessToken = AccessToken.Parse(response.AccessToken ?? throw new InvalidOperationException("Access token should not be null")), + AccessToken = AccessToken.Parse( + response.AccessToken ?? throw new InvalidOperationException("Access token should not be null"), + _tokenManagementOptions.TokenMaxLength), AccessTokenType = AccessTokenType.ParseOrDefault(response.TokenType), DPoPJsonWebKey = dpopJsonWebKey, Expiration = response.ExpiresIn == 0 diff --git a/access-token-management/src/AccessTokenManagement/RefreshToken.cs b/access-token-management/src/AccessTokenManagement/RefreshToken.cs index d0d49a234..4341d0634 100644 --- a/access-token-management/src/AccessTokenManagement/RefreshToken.cs +++ b/access-token-management/src/AccessTokenManagement/RefreshToken.cs @@ -10,14 +10,22 @@ namespace Duende.AccessTokenManagement; [JsonConverter(typeof(StringValueJsonConverter))] public readonly record struct RefreshToken : IStronglyTypedValue { - public const int MaxLength = 4 * 1024; + private const int Kilobyte = 1024; + + public const int MaxLength = 100 * Kilobyte; public override string ToString() => Value; private static readonly ValidationRule[] Validators = [ - // Officially, there's no max length refresh tokens, but 4k is a good limit + // Keep direct construction bounded. Runtime read boundaries apply the configurable limit. ValidationRules.MaxLength(MaxLength) ]; + private static ValidationRule[] BuildValidators(int maxLength) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength); + return [ValidationRules.MaxLength(maxLength)]; + } + /// /// You can't directly create this type. /// @@ -40,10 +48,30 @@ namespace Duende.AccessTokenManagement; public static bool TryParse(string value, [NotNullWhen(true)] out RefreshToken? parsed, out string[] errors) => IStronglyTypedValue.TryBuildValidatedObject(value, Validators, out parsed, out errors); + /// + /// Parses a value to a using the supplied maximum length. + /// + public static bool TryParse(string value, int maxLength, [NotNullWhen(true)] out RefreshToken? parsed, out string[] errors) => + IStronglyTypedValue.TryBuildValidatedObject(value, BuildValidators(maxLength), out parsed, out errors); + static RefreshToken IStronglyTypedValue.Create(string result) => new(result); /// /// Parses a value to a . This will throw an exception if the string is not valid. /// public static RefreshToken Parse(string value) => StringParsers.Parse(value); + + /// + /// Parses a value to a using the supplied maximum length. + /// + public static RefreshToken Parse(string value, int maxLength) + { + if (TryParse(value, maxLength, out var parsed, out var errors)) + { + return parsed.Value; + } + + throw new InvalidOperationException( + $"Received an invalid {nameof(RefreshToken)}. Errors: {string.Join("", errors.Select(x => $"{Environment.NewLine}\t - {x}"))}"); + } } diff --git a/access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs b/access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs index 68d8a82cf..4939f5ec5 100644 --- a/access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs +++ b/access-token-management/test/AccessTokenManagement.Tests/ClientTokenManagementTests.cs @@ -220,6 +220,30 @@ public async Task Missing_expires_in_response_should_create_long_lived_token() token.ShouldBeEquivalentTo(Some.ClientCredentialsToken()); } + [Fact] + public async Task Access_token_from_token_response_should_use_configured_token_max_length() + { + _services.AddClientCredentialsTokenManagement(options => options.TokenMaxLength = 8) + .AddClient("test", client => Some.ClientCredentialsClient(client)); + + _mockHttp.Expect(The.TokenEndpoint.ToString()) + .Respond(_ => Some.TokenHttpResponse(Some.Token() with + { + access_token = new string('a', 9) + })); + + _services.AddHttpClient(ClientCredentialsTokenManagementDefaults.BackChannelHttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => _mockHttp); + + var provider = _services.BuildServiceProvider(); + var sut = provider.GetRequiredService(); + + var action = async () => await sut.GetAccessTokenAsync(ClientCredentialsClientName.Parse("test"), ct: _ct); + + (await Should.ThrowAsync(action)) + .Message.ShouldContain("The string exceeds maximum length 8."); + } + [Fact] public async Task Request_parameters_should_take_precedence_over_configuration() { diff --git a/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi.verified.txt b/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi.verified.txt index ee596b9d7..f4d18177a 100644 --- a/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi.verified.txt +++ b/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi.verified.txt @@ -3,11 +3,13 @@ [System.Text.Json.Serialization.JsonConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueJsonConverter))] public readonly struct AccessToken : System.IEquatable { - public const int MaxLength = 32768; + public const int MaxLength = 102400; public AccessToken() { } public override string ToString() { } public static Duende.AccessTokenManagement.AccessToken Parse(string value) { } + public static Duende.AccessTokenManagement.AccessToken Parse(string value, int maxLength) { } public static bool TryParse(string value, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duende.AccessTokenManagement.AccessToken? parsed, out string[] errors) { } + public static bool TryParse(string value, int maxLength, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duende.AccessTokenManagement.AccessToken? parsed, out string[] errors) { } public static string op_Implicit(Duende.AccessTokenManagement.AccessToken value) { } } public sealed class AccessTokenRequestHandler : System.Net.Http.DelegatingHandler @@ -102,6 +104,7 @@ public System.TimeSpan DefaultCacheLifetime { get; set; } public System.TimeSpan? LocalCacheExpiration { get; set; } public string NonceStoreKeyPrefix { get; set; } + public int TokenMaxLength { get; set; } public bool UseCacheAutoTuning { get; set; } } [System.ComponentModel.TypeConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueConverter))] @@ -180,11 +183,13 @@ [System.Text.Json.Serialization.JsonConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueJsonConverter))] public readonly struct RefreshToken : System.IEquatable { - public const int MaxLength = 4096; + public const int MaxLength = 102400; public RefreshToken() { } public override string ToString() { } public static Duende.AccessTokenManagement.RefreshToken Parse(string value) { } + public static Duende.AccessTokenManagement.RefreshToken Parse(string value, int maxLength) { } public static bool TryParse(string value, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duende.AccessTokenManagement.RefreshToken? parsed, out string[] errors) { } + public static bool TryParse(string value, int maxLength, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duende.AccessTokenManagement.RefreshToken? parsed, out string[] errors) { } public static string op_Implicit(Duende.AccessTokenManagement.RefreshToken value) { } } [System.ComponentModel.TypeConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueConverter))] @@ -393,4 +398,4 @@ namespace Duende.AccessTokenManagement.OTel { public static System.Diagnostics.ActivitySource Main; } -} +} \ No newline at end of file diff --git a/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi_OpenIdConnect.verified.txt b/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi_OpenIdConnect.verified.txt index eeeacd315..82e5eb38f 100644 --- a/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi_OpenIdConnect.verified.txt +++ b/access-token-management/test/AccessTokenManagement.Tests/PublicApiVerificationTests.VerifyPublicApi_OpenIdConnect.verified.txt @@ -118,6 +118,7 @@ public Duende.AccessTokenManagement.Scope? ClientCredentialsScope { get; set; } public Duende.AccessTokenManagement.DPoP.DPoPProofKey? DPoPJsonWebKey { get; set; } public System.TimeSpan RefreshBeforeExpiration { get; set; } + public int TokenMaxLength { get; set; } public bool UseChallengeSchemeScopedTokens { get; set; } } public sealed class UserTokenRequestParameters : Duende.AccessTokenManagement.TokenRequestParameters, System.IEquatable @@ -126,4 +127,4 @@ public Duende.AccessTokenManagement.Scheme? ChallengeScheme { get; set; } public Duende.AccessTokenManagement.Scheme? SignInScheme { get; set; } } -} +} \ No newline at end of file diff --git a/access-token-management/test/AccessTokenManagement.Tests/StoreTokensInAuthenticationPropertiesTests.cs b/access-token-management/test/AccessTokenManagement.Tests/StoreTokensInAuthenticationPropertiesTests.cs index 28b77f46b..fe4ff65a5 100644 --- a/access-token-management/test/AccessTokenManagement.Tests/StoreTokensInAuthenticationPropertiesTests.cs +++ b/access-token-management/test/AccessTokenManagement.Tests/StoreTokensInAuthenticationPropertiesTests.cs @@ -38,6 +38,29 @@ public async Task Should_be_able_to_store_and_retrieve_tokens() CompareRefreshToken(result, userToken); } + [Fact] + public void Should_validate_access_token_length_using_options_when_retrieving_tokens() + { + var authenticationProperties = new AuthenticationProperties(); + var sut = new StoreTokensInAuthenticationProperties( + new TestOptionsMonitor(new UserTokenManagementOptions + { + TokenMaxLength = 8 + }), + new TestOptionsMonitor(), + new TestSchemeProvider(), + new NullLogger() + ); + + authenticationProperties.Items[".Token.access_token"] = new string('a', 9); + authenticationProperties.Items[".Token.refresh_token"] = "refresh"; + authenticationProperties.Items[".Token.client_id"] = "client"; + + var exception = Should.Throw(() => sut.GetUserToken(authenticationProperties)); + + exception.Message.ShouldContain("The string exceeds maximum length 8."); + } + private static void CompareRefreshToken(TokenResult result, UserToken userToken) { var userRefreshToken = result.Token!.RefreshToken.ShouldNotBeNull(); diff --git a/access-token-management/test/AccessTokenManagement.Tests/UserTokenManagementTests.cs b/access-token-management/test/AccessTokenManagement.Tests/UserTokenManagementTests.cs index bdb02d949..7309a6e7e 100644 --- a/access-token-management/test/AccessTokenManagement.Tests/UserTokenManagementTests.cs +++ b/access-token-management/test/AccessTokenManagement.Tests/UserTokenManagementTests.cs @@ -230,6 +230,47 @@ public async Task Missing_initial_refresh_token_and_expired_access_token_should_ token.Expiration.ShouldNotBe(DateTimeOffset.MaxValue); } + [Fact] + public async Task Refresh_token_response_should_use_configured_token_max_length() + { + var mockHttp = new MockHttpMessageHandler(); + AppHost.IdentityServerHttpHandler = mockHttp; + + var initialTokenResponse = new + { + id_token = IdentityServerHost.CreateIdToken("1", "web"), + access_token = "initial_access_token", + token_type = "tokentype", + expires_in = 10, + refresh_token = "initial_refresh_token", + }; + mockHttp.When("/connect/token") + .WithFormData("grant_type", "authorization_code") + .Respond("application/json", JsonSerializer.Serialize(initialTokenResponse)); + + var refreshTokenResponse = new + { + access_token = new string('a', 9), + token_type = "tokentype1", + expires_in = 3600, + refresh_token = "refreshed_refresh_token", + }; + mockHttp.When("/connect/token") + .WithFormData("grant_type", "refresh_token") + .WithFormData("refresh_token", "initial_refresh_token") + .Respond("application/json", JsonSerializer.Serialize(refreshTokenResponse)); + + AppHost.OnConfigureServices += services => services.Configure(options => options.TokenMaxLength = 8); + + await InitializeAsync(); + await AppHost.LoginAsync("alice"); + + var action = async () => await AppHost.BrowserClient.GetAsync(AppHost.Url("/user_token"), _ct); + + (await Should.ThrowAsync(action)) + .Message.ShouldContain("The string exceeds maximum length 8."); + } + [Fact] public async Task Short_token_lifetime_should_trigger_refresh() {