Skip to content

Commit 73f2d47

Browse files
committed
Fix Several Refresh Issues
1 parent 1989a5b commit 73f2d47

9 files changed

Lines changed: 139 additions & 18 deletions

File tree

‎src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options)
3434

3535
if (options.IssueOpaque)
3636
{
37-
if (options.OpaqueIdBytes < 16)
38-
errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy).");
37+
if (options.OpaqueIdBytes < 24)
38+
errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy).");
3939

4040
if (options.OpaqueIdBytes > 128)
4141
errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes.");

‎src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ UAuthMode.SemiHybrid or
5252

5353
public async Task<RefreshTokenIssuanceResult?> IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default)
5454
{
55-
if (flow.EffectiveMode == UAuthMode.PureOpaque)
55+
if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh)
5656
return null;
5757

5858
if (context.SessionId is not AuthSessionId sessionId)

‎src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ public sealed class UAuthServerOptions
133133
/// </summary>
134134
public Action<IEndpointRouteBuilder>? OnConfigureEndpoints { get; set; }
135135

136+
// TODO: Add same startup validations. Mode-based options should not pass through the option invariant.
136137
internal Dictionary<UAuthMode, Action<UAuthServerOptions>> ModeConfigurations { get; set; } = new();
137138

138139

‎src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
2121
if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero)
2222
errors.Add("Session.IdleTimeout cannot be negative.");
2323

24-
if (options.Session.IdleTimeout is { } idle &&
25-
options.Session.TouchInterval is { } touch &&
26-
idle > TimeSpan.Zero && touch >= idle)
24+
if (options.Session.IdleTimeout is { } idle && options.Session.TouchInterval is { } touch && idle > TimeSpan.Zero && touch >= idle)
2725
{
2826
errors.Add("Session.TouchInterval must be less than Session.IdleTimeout.");
2927
}
3028

29+
if (session.SlidingExpiration && session.TouchInterval is { } touchInterval && touchInterval >= session.Lifetime)
30+
{
31+
errors.Add(
32+
"Session.TouchInterval must be less than Session.Lifetime when SlidingExpiration is enabled.");
33+
}
34+
3135
return errors.Count == 0
3236
? ValidateOptionsResult.Success
3337
: ValidateOptionsResult.Fail(errors);

‎src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
4040

4141
if (tokens.IssueOpaque)
4242
{
43-
if (tokens.OpaqueIdBytes < 16)
44-
errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy).");
43+
if (tokens.OpaqueIdBytes < 24)
44+
errors.Add("Token.OpaqueIdBytes must be at least 24 bytes (192-bit entropy).");
4545

4646
if (tokens.OpaqueIdBytes > 128)
4747
errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes.");

‎src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTok
2828
// TODO: Handle reuse detection and make flow knows situation, but don't make security branch.
2929
public async Task<RefreshTokenRotationExecution> RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default)
3030
{
31+
if (flow.EffectiveMode == UAuthMode.PureOpaque || !flow.Options.Token.IssueRefresh)
32+
{
33+
return new RefreshTokenRotationExecution
34+
{
35+
Result = RefreshTokenRotationResult.Failed()
36+
};
37+
}
38+
3139
var validation = await _validator.ValidateAsync(
3240
new RefreshTokenValidationContext
3341
{

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,56 @@ await act.Should()
620620
Times.Never);
621621
}
622622

623+
[Fact]
624+
public async Task RotateAsync_WhenIssueRefreshDisabled_ShouldFailWithoutValidationOrMutation()
625+
{
626+
var validator = new Mock<IRefreshTokenValidator>();
627+
var storeFactory = new Mock<IRefreshTokenStoreFactory>();
628+
var issuer = new Mock<ITokenIssuer>();
629+
var claimsProvider = new Mock<IUserClaimsProvider>();
630+
631+
var flow = CreateFlow();
632+
flow.Options.Token.IssueRefresh = false;
633+
634+
var sut = new RefreshTokenRotationService(
635+
validator.Object,
636+
storeFactory.Object,
637+
issuer.Object,
638+
claimsProvider.Object);
639+
640+
var result = await sut.RotateAsync(
641+
flow,
642+
CreateContext());
643+
644+
result.Result.IsSuccess.Should().BeFalse();
645+
result.Result.ReauthRequired.Should().BeTrue();
646+
647+
validator.Verify(
648+
x => x.ValidateAsync(
649+
It.IsAny<RefreshTokenValidationContext>(),
650+
It.IsAny<CancellationToken>()),
651+
Times.Never);
652+
653+
storeFactory.Verify(
654+
x => x.Create(It.IsAny<TenantKey>()),
655+
Times.Never);
656+
657+
issuer.Verify(
658+
x => x.IssueRefreshTokenAsync(
659+
It.IsAny<AuthFlowContext>(),
660+
It.IsAny<TokenIssuanceContext>(),
661+
It.IsAny<RefreshTokenPersistence>(),
662+
It.IsAny<CancellationToken>()),
663+
Times.Never);
664+
665+
issuer.Verify(
666+
x => x.IssueAccessTokenAsync(
667+
It.IsAny<AuthFlowContext>(),
668+
It.IsAny<TokenIssuanceContext>(),
669+
It.IsAny<CancellationToken>()),
670+
Times.Never);
671+
}
672+
623673

624674
private static Mock<IRefreshTokenValidator> CreateValidator(RefreshTokenValidationResult result)
625675
{

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ public void Token_opaque_entropy_outside_limits_fails(int bytes) =>
708708
}, "OpaqueIdBytes");
709709

710710
[Theory]
711-
[InlineData(16)]
711+
[InlineData(24)]
712712
[InlineData(128)]
713713
public void Token_opaque_entropy_boundary_values_pass(int bytes) =>
714714
ShouldAccept<UAuthServerTokenOptionsValidator>(o =>
@@ -928,6 +928,36 @@ public void Server_null_session_max_lifetime_passes_under_current_rules() =>
928928
});
929929

930930

931+
[Theory]
932+
[InlineData(1, 5)]
933+
[InlineData(5, 5)]
934+
public void Session_sliding_expiration_touch_interval_greater_than_or_equal_to_lifetime_fails(
935+
int lifetimeMinutes,
936+
int touchIntervalMinutes) =>
937+
ShouldReject<UAuthServerSessionOptionsValidator>(o =>
938+
{
939+
o.Session.SlidingExpiration = true;
940+
o.Session.Lifetime = TimeSpan.FromMinutes(lifetimeMinutes);
941+
o.Session.TouchInterval = TimeSpan.FromMinutes(touchIntervalMinutes);
942+
o.Session.IdleTimeout = null;
943+
o.Session.MaxLifetime = null;
944+
}, "Session.TouchInterval must be less than Session.Lifetime");
945+
946+
947+
[Fact]
948+
public void Session_sliding_expiration_touch_interval_less_than_lifetime_passes() =>
949+
ShouldAccept<UAuthServerSessionOptionsValidator>(o =>
950+
{
951+
o.Session.SlidingExpiration = true;
952+
o.Session.Lifetime = TimeSpan.FromMinutes(5);
953+
o.Session.TouchInterval = TimeSpan.FromMinutes(1);
954+
o.Session.IdleTimeout = null;
955+
o.Session.MaxLifetime = null;
956+
});
957+
958+
959+
960+
931961
private static void ShouldReject<TValidator>(Action<UAuthServerOptions> configure, string expected)
932962
where TValidator : class, IValidateOptions<UAuthServerOptions>, new()
933963
{

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/UAuthTokenIssuerTests.cs‎

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,37 @@ public async Task IssueTokens_ShouldUseEffectiveLifetimes()
11011101
refresh!.ExpiresAt.Should().Be(Now.AddMinutes(30));
11021102
}
11031103

1104+
[Theory]
1105+
[InlineData(UAuthMode.Hybrid)]
1106+
[InlineData(UAuthMode.SemiHybrid)]
1107+
[InlineData(UAuthMode.PureJwt)]
1108+
public async Task IssueRefreshTokenAsync_WhenIssueRefreshDisabled_ShouldReturnNull(UAuthMode mode)
1109+
{
1110+
var fixture = CreateFixture();
1111+
var flow = CreateFlow(mode);
1112+
1113+
flow.Options.Token.IssueRefresh = false;
1114+
1115+
var result = await fixture.Sut.IssueRefreshTokenAsync(
1116+
flow,
1117+
CreateTokenContext(sessionId: null),
1118+
RefreshTokenPersistence.Persist);
1119+
1120+
result.Should().BeNull();
1121+
1122+
fixture.OpaqueGenerator.Verify(
1123+
x => x.Generate(),
1124+
Times.Never);
1125+
1126+
fixture.Hasher.Verify(
1127+
x => x.Hash(It.IsAny<string>()),
1128+
Times.Never);
1129+
1130+
fixture.StoreFactory.Verify(
1131+
x => x.Create(It.IsAny<TenantKey>()),
1132+
Times.Never);
1133+
}
1134+
11041135
// =====================================================================
11051136
// Context helpers
11061137
// =====================================================================
@@ -1112,7 +1143,8 @@ private static AuthFlowContext CreateFlow(
11121143
string? issuer = null,
11131144
string? audience = null,
11141145
string? keyId = null,
1115-
bool addJwtIdClaim = true)
1146+
bool addJwtIdClaim = true,
1147+
bool issueRefresh = true)
11161148
{
11171149
/*
11181150
* Adapt only this helper to the existing AuthFlowContext builder/helper
@@ -1124,15 +1156,11 @@ private static AuthFlowContext CreateFlow(
11241156
* - OriginalOptions.Token values below
11251157
*/
11261158

1127-
var flow = AuthFlowTestFactory.New(
1128-
mode: mode,
1129-
tenant: Tenant);
1130-
1131-
flow.Options.Token.AccessTokenLifetime =
1132-
accessTokenLifetime ?? TimeSpan.FromMinutes(15);
1159+
var flow = AuthFlowTestFactory.New(mode: mode, tenant: Tenant);
11331160

1134-
flow.Options.Token.RefreshTokenLifetime =
1135-
refreshTokenLifetime ?? TimeSpan.FromDays(30);
1161+
flow.Options.Token.AccessTokenLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15);
1162+
flow.Options.Token.RefreshTokenLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30);
1163+
flow.Options.Token.IssueRefresh = issueRefresh;
11361164

11371165
if (issuer is not null)
11381166
flow.Options.Token.Issuer = issuer;

0 commit comments

Comments
 (0)