Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ internal class OpenIdConnectUserTokenEndpoint(
IDPoPProofService dPoPProofService,
ILogger<OpenIdConnectUserTokenEndpoint> logger) : IOpenIdConnectUserTokenEndpoint
{
private readonly UserTokenManagementOptions _options = options.Value;

/// <inheritdoc/>
public async Task<TokenResult<UserToken>> RefreshAccessTokenAsync(
UserRefreshToken refreshToken,
Expand All @@ -47,7 +49,7 @@ public async Task<TokenResult<UserToken>> 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
};
Expand Down Expand Up @@ -156,16 +158,17 @@ public async Task<TokenResult<UserToken>> 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
? DateTimeOffset.MaxValue
: 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
};
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ private static string NamePrefixAndResourceSuffix(string type, UserTokenRequestP
/// <inheritdoc/>
public TokenResult<TokenForParameters> 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())
{
Expand Down Expand Up @@ -81,7 +82,7 @@ public TokenResult<TokenForParameters> 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)
Expand All @@ -91,7 +92,9 @@ public TokenResult<TokenForParameters> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ namespace Duende.AccessTokenManagement.OpenIdConnect;
/// </summary>
public sealed class UserTokenManagementOptions
{
private const int Kilobyte = 1024;

/// <summary>
/// Maximum allowed token length when reading tokens from token responses or authentication state.
/// </summary>
public int TokenMaxLength { get; set; } = 4 * Kilobyte;
Comment on lines +19 to +20
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new default TokenMaxLength of 4 KB is a breaking tightening compared to the previous behavior (access tokens were previously allowed up to 32 KB via AccessToken.MaxLength). This will cause existing deployments with larger access tokens to start failing unless they opt in. If the intent is to preserve the prior default behavior while making it configurable, consider defaulting this to 32 KB (and documenting how to raise it for unusually large refresh tokens).

Suggested change
/// </summary>
public int TokenMaxLength { get; set; } = 4 * Kilobyte;
/// Defaults to 32 KB to preserve the historical access token limit; increase if needed for unusually large tokens.
/// </summary>
public int TokenMaxLength { get; set; } = 32 * Kilobyte;

Copilot uses AI. Check for mistakes.

/// <summary>
/// Name of the authentication scheme to use for deriving token service configuration
/// (will fall back to configured default challenge scheme if not set)
Expand Down
33 changes: 31 additions & 2 deletions access-token-management/src/AccessTokenManagement/AccessToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,24 @@ namespace Duende.AccessTokenManagement;
[JsonConverter(typeof(StringValueJsonConverter<AccessToken>))]
public readonly record struct AccessToken : IStronglyTypedValue<AccessToken>
{
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<string>[] Validators = [
ValidationRules.MaxLength(MaxLength)
];

private static ValidationRule<string>[] BuildValidators(int maxLength)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength);
return [ValidationRules.MaxLength(maxLength)];
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildValidators(maxLength) allows callers to pass a maxLength larger than AccessToken.MaxLength, which effectively bypasses the intended hard cap (“keep construction bounded”) and can allow arbitrarily large tokens if consumers configure a large value. Consider clamping to MaxLength or throwing when maxLength > MaxLength so MaxLength remains an actual upper bound.

Suggested change
return [ValidationRules.MaxLength(maxLength)];
return [ValidationRules.MaxLength(Math.Min(maxLength, MaxLength))];

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Convenience method for converting a <see cref="AccessToken"/> into a string.
/// </summary>
Expand All @@ -44,10 +53,30 @@ namespace Duende.AccessTokenManagement;
public static bool TryParse(string value, [NotNullWhen(true)] out AccessToken? parsed, out string[] errors) =>
IStronglyTypedValue<AccessToken>.TryBuildValidatedObject(value, Validators, out parsed, out errors);

/// <summary>
/// Parses a value to a <see cref="AccessToken"/> using the supplied maximum length.
/// </summary>
public static bool TryParse(string value, int maxLength, [NotNullWhen(true)] out AccessToken? parsed, out string[] errors) =>
IStronglyTypedValue<AccessToken>.TryBuildValidatedObject(value, BuildValidators(maxLength), out parsed, out errors);

static AccessToken IStronglyTypedValue<AccessToken>.Create(string result) => new(result);

/// <summary>
/// Parses a value to a <see cref="AccessToken"/>. This will throw an exception if the string is not valid.
/// </summary>
public static AccessToken Parse(string value) => StringParsers<AccessToken>.Parse(value);

/// <summary>
/// Parses a value to a <see cref="AccessToken"/> using the supplied maximum length.
/// </summary>
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}"))}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ namespace Duende.AccessTokenManagement;
/// </summary>
public sealed class ClientCredentialsTokenManagementOptions
{
private const int Kilobyte = 1024;

/// <summary>
/// Maximum allowed token length when reading tokens from external systems or caches.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc says TokenMaxLength applies when reading tokens from “external systems or caches”, but TokenMaxLength is only used when parsing the token response; cache rehydration uses JSON deserialization (and thus the value-object’s static MaxLength), not this option. Please update the doc to match the actual behavior (or apply TokenMaxLength during cache deserialization if that’s the intended contract).

Suggested change
/// Maximum allowed token length when reading tokens from external systems or caches.
/// Maximum allowed token length when parsing token responses.

Copilot uses AI. Check for mistakes.
/// </summary>
public int TokenMaxLength { get; set; } = 32 * Kilobyte;

/// <summary>
/// Used to prefix the cache key
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ internal class ClientCredentialsTokenClient(
AccessTokenManagementMetrics metrics,
IHttpClientFactory httpClientFactory,
IOptionsMonitor<ClientCredentialsClient> options,
IOptions<ClientCredentialsTokenManagementOptions> tokenManagementOptions,
IClientAssertionService clientAssertionService,
TimeProvider time,
IDPoPKeyStore dPoPKeyMaterialService,
IDPoPProofService dPoPProofService,
ILogger<ClientCredentialsTokenClient> logger) : IClientCredentialsTokenEndpoint
{
private readonly ClientCredentialsTokenManagementOptions _tokenManagementOptions = tokenManagementOptions.Value;

/// <inheritdoc/>
public virtual async Task<TokenResult<ClientCredentialsToken>> RequestAccessTokenAsync(
ClientCredentialsClientName clientName,
Expand Down Expand Up @@ -158,7 +161,9 @@ public virtual async Task<TokenResult<ClientCredentialsToken>> 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
Expand Down
32 changes: 30 additions & 2 deletions access-token-management/src/AccessTokenManagement/RefreshToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@ namespace Duende.AccessTokenManagement;
[JsonConverter(typeof(StringValueJsonConverter<RefreshToken>))]
public readonly record struct RefreshToken : IStronglyTypedValue<RefreshToken>
{
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<string>[] 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<string>[] BuildValidators(int maxLength)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength);
return [ValidationRules.MaxLength(maxLength)];
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildValidators(maxLength) currently permits maxLength values above RefreshToken.MaxLength, which defeats the purpose of having a hard maximum length for the value object. To keep construction bounded as intended, clamp maxLength to MaxLength or throw when maxLength > MaxLength.

Suggested change
return [ValidationRules.MaxLength(maxLength)];
var effectiveMaxLength = Math.Min(maxLength, MaxLength);
return [ValidationRules.MaxLength(effectiveMaxLength)];

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// You can't directly create this type.
/// </summary>
Expand All @@ -40,10 +48,30 @@ namespace Duende.AccessTokenManagement;
public static bool TryParse(string value, [NotNullWhen(true)] out RefreshToken? parsed, out string[] errors) =>
IStronglyTypedValue<RefreshToken>.TryBuildValidatedObject(value, Validators, out parsed, out errors);

/// <summary>
/// Parses a value to a <see cref="RefreshToken"/> using the supplied maximum length.
/// </summary>
public static bool TryParse(string value, int maxLength, [NotNullWhen(true)] out RefreshToken? parsed, out string[] errors) =>
IStronglyTypedValue<RefreshToken>.TryBuildValidatedObject(value, BuildValidators(maxLength), out parsed, out errors);

static RefreshToken IStronglyTypedValue<RefreshToken>.Create(string result) => new(result);

/// <summary>
/// Parses a value to a <see cref="RefreshToken"/>. This will throw an exception if the string is not valid.
/// </summary>
public static RefreshToken Parse(string value) => StringParsers<RefreshToken>.Parse(value);

/// <summary>
/// Parses a value to a <see cref="RefreshToken"/> using the supplied maximum length.
/// </summary>
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}"))}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IClientCredentialsTokenManager>();

var action = async () => await sut.GetAccessTokenAsync(ClientCredentialsClientName.Parse("test"), ct: _ct);

(await Should.ThrowAsync<InvalidOperationException>(action))
.Message.ShouldContain("The string exceeds maximum length 8.");
}

[Fact]
public async Task Request_parameters_should_take_precedence_over_configuration()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
[System.Text.Json.Serialization.JsonConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueJsonConverter<Duende.AccessTokenManagement.AccessToken>))]
public readonly struct AccessToken : System.IEquatable<Duende.AccessTokenManagement.AccessToken>
{
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
Expand Down Expand Up @@ -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<Duende.AccessTokenManagement.ClientId>))]
Expand Down Expand Up @@ -180,11 +183,13 @@
[System.Text.Json.Serialization.JsonConverter(typeof(Duende.AccessTokenManagement.Internal.StringValueJsonConverter<Duende.AccessTokenManagement.RefreshToken>))]
public readonly struct RefreshToken : System.IEquatable<Duende.AccessTokenManagement.RefreshToken>
{
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<Duende.AccessTokenManagement.Resource>))]
Expand Down Expand Up @@ -393,4 +398,4 @@ namespace Duende.AccessTokenManagement.OTel
{
public static System.Diagnostics.ActivitySource Main;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duende.AccessTokenManagement.OpenIdConnect.UserTokenRequestParameters>
Expand All @@ -126,4 +127,4 @@
public Duende.AccessTokenManagement.Scheme? ChallengeScheme { get; set; }
public Duende.AccessTokenManagement.Scheme? SignInScheme { get; set; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserTokenManagementOptions>(new UserTokenManagementOptions
{
TokenMaxLength = 8
}),
new TestOptionsMonitor<CookieAuthenticationOptions>(),
new TestSchemeProvider(),
new NullLogger<StoreTokensInAuthenticationProperties>()
);

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<InvalidOperationException>(() => sut.GetUserToken(authenticationProperties));

exception.Message.ShouldContain("The string exceeds maximum length 8.");
}

private static void CompareRefreshToken(TokenResult<TokenForParameters> result, UserToken userToken)
{
var userRefreshToken = result.Token!.RefreshToken.ShouldNotBeNull();
Expand Down
Loading
Loading