Skip to content
Merged
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 @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ UAuthMode.SemiHybrid or

public async Task<RefreshTokenIssuanceResult?> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public sealed class UAuthServerOptions
/// </summary>
public Action<IEndpointRouteBuilder>? OnConfigureEndpoints { get; set; }

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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefreshTokenRotationExecution> 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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,56 @@ await act.Should()
Times.Never);
}

[Fact]
public async Task RotateAsync_WhenIssueRefreshDisabled_ShouldFailWithoutValidationOrMutation()
{
var validator = new Mock<IRefreshTokenValidator>();
var storeFactory = new Mock<IRefreshTokenStoreFactory>();
var issuer = new Mock<ITokenIssuer>();
var claimsProvider = new Mock<IUserClaimsProvider>();

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<RefreshTokenValidationContext>(),
It.IsAny<CancellationToken>()),
Times.Never);

storeFactory.Verify(
x => x.Create(It.IsAny<TenantKey>()),
Times.Never);

issuer.Verify(
x => x.IssueRefreshTokenAsync(
It.IsAny<AuthFlowContext>(),
It.IsAny<TokenIssuanceContext>(),
It.IsAny<RefreshTokenPersistence>(),
It.IsAny<CancellationToken>()),
Times.Never);

issuer.Verify(
x => x.IssueAccessTokenAsync(
It.IsAny<AuthFlowContext>(),
It.IsAny<TokenIssuanceContext>(),
It.IsAny<CancellationToken>()),
Times.Never);
}


private static Mock<IRefreshTokenValidator> CreateValidator(RefreshTokenValidationResult result)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UAuthServerTokenOptionsValidator>(o =>
Expand Down Expand Up @@ -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<UAuthServerSessionOptionsValidator>(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<UAuthServerSessionOptionsValidator>(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<TValidator>(Action<UAuthServerOptions> configure, string expected)
where TValidator : class, IValidateOptions<UAuthServerOptions>, new()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()),
Times.Never);

fixture.StoreFactory.Verify(
x => x.Create(It.IsAny<TenantKey>()),
Times.Never);
}

// =====================================================================
// Context helpers
// =====================================================================
Expand All @@ -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
Expand All @@ -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;
Expand Down
Loading