From 73f2d47267f59b43d0e3daae65112e1cfdf3e114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Mon, 12 Oct 2026 22:33:21 +0300 Subject: [PATCH] Fix Several Refresh Issues --- .../Validators/UAuthTokenOptionsValidator.cs | 4 +- .../Issuers/UAuthTokenIssuer.cs | 2 +- .../Options/UAuthServerOptions.cs | 1 + .../UAuthServerSessionOptionsValidator.cs | 10 ++-- .../UAuthServerTokenOptionsValidator.cs | 4 +- .../Services/RefreshTokenRotationService.cs | 8 +++ .../RefreshTokenRotationServiceTests.cs | 50 +++++++++++++++++++ .../Server/ServerOptionsValidatorTests.cs | 32 +++++++++++- .../Tokens/UAuthTokenIssuerTests.cs | 46 +++++++++++++---- 9 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs index 9d774c7d..8b211b4a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -34,8 +34,8 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) if (options.IssueOpaque) { - if (options.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + if (options.OpaqueIdBytes < 24) + errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy)."); if (options.OpaqueIdBytes > 128) errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index 36bdbd27..23223858 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -52,7 +52,7 @@ UAuthMode.SemiHybrid or public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { - if (flow.EffectiveMode == UAuthMode.PureOpaque) + if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh) return null; if (context.SessionId is not AuthSessionId sessionId) diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 34af5b93..1c04962c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -133,6 +133,7 @@ public sealed class UAuthServerOptions /// public Action? OnConfigureEndpoints { get; set; } + // TODO: Add same startup validations. Mode-based options should not pass through the option invariant. internal Dictionary> ModeConfigurations { get; set; } = new(); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs index b2d9970b..8b7baf0b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -21,13 +21,17 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) errors.Add("Session.IdleTimeout cannot be negative."); - if (options.Session.IdleTimeout is { } idle && - options.Session.TouchInterval is { } touch && - idle > TimeSpan.Zero && touch >= idle) + if (options.Session.IdleTimeout is { } idle && options.Session.TouchInterval is { } touch && idle > TimeSpan.Zero && touch >= idle) { errors.Add("Session.TouchInterval must be less than Session.IdleTimeout."); } + if (session.SlidingExpiration && session.TouchInterval is { } touchInterval && touchInterval >= session.Lifetime) + { + errors.Add( + "Session.TouchInterval must be less than Session.Lifetime when SlidingExpiration is enabled."); + } + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs index f6107b7d..fe445470 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs @@ -40,8 +40,8 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) if (tokens.IssueOpaque) { - if (tokens.OpaqueIdBytes < 16) - errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + if (tokens.OpaqueIdBytes < 24) + errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy)."); if (tokens.OpaqueIdBytes > 128) errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 56471b93..ae1fb1a6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -28,6 +28,14 @@ public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTok // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) { + if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh) + { + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; + } + var validation = await _validator.ValidateAsync( new RefreshTokenValidationContext { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 5442498c..fa7131c5 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -620,6 +620,56 @@ await act.Should() Times.Never); } + [Fact] + public async Task RotateAsync_WhenIssueRefreshDisabled_ShouldFailWithoutValidationOrMutation() + { + var validator = new Mock(); + var storeFactory = new Mock(); + var issuer = new Mock(); + var claimsProvider = new Mock(); + + var flow = CreateFlow(); + flow.Options.Token.IssueRefresh = false; + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object, + claimsProvider.Object); + + var result = await sut.RotateAsync( + flow, + CreateContext()); + + result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); + + validator.Verify( + x => x.ValidateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + storeFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + + issuer.Verify( + x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + issuer.Verify( + x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + private static Mock CreateValidator(RefreshTokenValidationResult result) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs index 781d89f2..dd98580b 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -708,7 +708,7 @@ public void Token_opaque_entropy_outside_limits_fails(int bytes) => }, "OpaqueIdBytes"); [Theory] - [InlineData(16)] + [InlineData(24)] [InlineData(128)] public void Token_opaque_entropy_boundary_values_pass(int bytes) => ShouldAccept(o => @@ -928,6 +928,36 @@ public void Server_null_session_max_lifetime_passes_under_current_rules() => }); + [Theory] + [InlineData(1, 5)] + [InlineData(5, 5)] + public void Session_sliding_expiration_touch_interval_greater_than_or_equal_to_lifetime_fails( + int lifetimeMinutes, + int touchIntervalMinutes) => + ShouldReject(o => + { + o.Session.SlidingExpiration = true; + o.Session.Lifetime = TimeSpan.FromMinutes(lifetimeMinutes); + o.Session.TouchInterval = TimeSpan.FromMinutes(touchIntervalMinutes); + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; + }, "Session.TouchInterval must be less than Session.Lifetime"); + + + [Fact] + public void Session_sliding_expiration_touch_interval_less_than_lifetime_passes() => + ShouldAccept(o => + { + o.Session.SlidingExpiration = true; + o.Session.Lifetime = TimeSpan.FromMinutes(5); + o.Session.TouchInterval = TimeSpan.FromMinutes(1); + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; + }); + + + + private static void ShouldReject(Action configure, string expected) where TValidator : class, IValidateOptions, new() { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs index 6b2ff195..0f094f11 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs @@ -1101,6 +1101,37 @@ public async Task IssueTokens_ShouldUseEffectiveLifetimes() refresh!.ExpiresAt.Should().Be(Now.AddMinutes(30)); } + [Theory] + [InlineData(UAuthMode.Hybrid)] + [InlineData(UAuthMode.SemiHybrid)] + [InlineData(UAuthMode.PureJwt)] + public async Task IssueRefreshTokenAsync_WhenIssueRefreshDisabled_ShouldReturnNull(UAuthMode mode) + { + var fixture = CreateFixture(); + var flow = CreateFlow(mode); + + flow.Options.Token.IssueRefresh = false; + + var result = await fixture.Sut.IssueRefreshTokenAsync( + flow, + CreateTokenContext(sessionId: null), + RefreshTokenPersistence.Persist); + + result.Should().BeNull(); + + fixture.OpaqueGenerator.Verify( + x => x.Generate(), + Times.Never); + + fixture.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + + fixture.StoreFactory.Verify( + x => x.Create(It.IsAny()), + Times.Never); + } + // ===================================================================== // Context helpers // ===================================================================== @@ -1112,7 +1143,8 @@ private static AuthFlowContext CreateFlow( string? issuer = null, string? audience = null, string? keyId = null, - bool addJwtIdClaim = true) + bool addJwtIdClaim = true, + bool issueRefresh = true) { /* * Adapt only this helper to the existing AuthFlowContext builder/helper @@ -1124,15 +1156,11 @@ private static AuthFlowContext CreateFlow( * - OriginalOptions.Token values below */ - var flow = AuthFlowTestFactory.New( - mode: mode, - tenant: Tenant); - - flow.Options.Token.AccessTokenLifetime = - accessTokenLifetime ?? TimeSpan.FromMinutes(15); + var flow = AuthFlowTestFactory.New(mode: mode, tenant: Tenant); - flow.Options.Token.RefreshTokenLifetime = - refreshTokenLifetime ?? TimeSpan.FromDays(30); + flow.Options.Token.AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15); + flow.Options.Token.RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30); + flow.Options.Token.IssueRefresh = issueRefresh; if (issuer is not null) flow.Options.Token.Issuer = issuer;