Skip to content

Commit 5b392ad

Browse files
authored
Integration Test Enhancement (#59)
* Integration Test Enhancement * Test Fixes * Fix Tests 2 * Test Fixes 3 * Login Tests Completion * Logout Tests * Session Tests * Refresh Tests * Finalize Auth Tests * False & Positive Reuse Detection
1 parent 5fdc72f commit 5b392ad

49 files changed

Lines changed: 14648 additions & 781 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ public interface IAuthenticationSecurityManager
88
{
99
Task<AuthenticationSecurityState> GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
1010
Task<AuthenticationSecurityState> GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default);
11+
Task<AuthenticationSecurityState> MutateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, Func<AuthenticationSecurityState, AuthenticationSecurityState> mutation, CancellationToken ct = default);
12+
Task<AuthenticationSecurityState> MutateAccountAsync(TenantKey tenant, UserKey userKey, Func<AuthenticationSecurityState, AuthenticationSecurityState> mutation, CancellationToken ct = default);
1113
Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default);
1214
Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default);
1315
}

‎src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public interface IRefreshTokenStore
1111

1212
Task<RefreshToken?> FindByHashAsync(string tokenHash, CancellationToken ct = default);
1313

14+
Task<bool> TryConsumeAsync(string tokenHash, DateTimeOffset consumedAt, string replacedByTokenHash, CancellationToken ct = default);
15+
1416
Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default);
1517

1618
Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default);

‎src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ public interface ISessionStore
2525
Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default);
2626
Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default);
2727

28-
Task<UAuthSessionRoot?> GetRootByUserAsync(UserKey userKey, CancellationToken ct = default);
28+
//Task<UAuthSessionRoot?> GetRootByUserAsync(UserKey userKey, CancellationToken ct = default);
29+
Task<UAuthSessionRoot?> GetActiveRootByUserAsync(UserKey userKey, CancellationToken ct = default);
2930
Task<UAuthSessionRoot?> GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default);
3031
Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default);
3132
Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default);

‎src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs‎

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,59 +5,76 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
55

66
public sealed record RefreshTokenValidationResult
77
{
8-
public bool IsValid { get; init; }
9-
public bool IsReuseDetected { get; init; }
8+
public RefreshTokenValidationState State { get; init; }
9+
10+
public bool IsValid => State == RefreshTokenValidationState.Valid;
1011

1112
public string? TokenHash { get; init; }
1213

1314
public TenantKey Tenant { get; init; }
15+
1416
public UserKey? UserKey { get; init; }
17+
1518
public AuthSessionId? SessionId { get; init; }
19+
1620
public SessionChainId? ChainId { get; init; }
1721

1822
public DateTimeOffset? ExpiresAt { get; init; }
23+
public DateTimeOffset? ConsumedAt { get; init; }
1924

25+
public string? ReplacedByTokenHash { get; init; }
2026

21-
private RefreshTokenValidationResult() { }
27+
private RefreshTokenValidationResult()
28+
{
29+
}
2230

2331
public static RefreshTokenValidationResult Invalid()
2432
=> new()
2533
{
26-
IsValid = false,
27-
IsReuseDetected = false
34+
State = RefreshTokenValidationState.Invalid
2835
};
2936

30-
public static RefreshTokenValidationResult ReuseDetected(
31-
TenantKey tenant,
32-
AuthSessionId? sessionId = null,
33-
string? tokenHash = null,
34-
SessionChainId? chainId = null,
35-
UserKey? userKey = default)
36-
=> new()
37-
{
38-
IsValid = false,
39-
IsReuseDetected = true,
40-
Tenant = tenant,
41-
SessionId = sessionId,
42-
TokenHash = tokenHash,
43-
ChainId = chainId,
44-
UserKey = userKey,
45-
};
46-
47-
public static RefreshTokenValidationResult Valid(
48-
TenantKey tenant,
49-
UserKey userKey,
50-
AuthSessionId sessionId,
51-
string? tokenHash,
52-
SessionChainId? chainId = null)
53-
=> new()
54-
{
55-
IsValid = true,
56-
IsReuseDetected = false,
57-
Tenant = tenant,
58-
UserKey = userKey,
59-
SessionId = sessionId,
60-
ChainId = chainId,
61-
TokenHash = tokenHash
62-
};
63-
}
37+
public static RefreshTokenValidationResult NotFound()
38+
=> new()
39+
{
40+
State = RefreshTokenValidationState.NotFound
41+
};
42+
43+
public static RefreshTokenValidationResult Expired(RefreshToken token)
44+
=> FromToken(token, RefreshTokenValidationState.Expired);
45+
46+
public static RefreshTokenValidationResult Consumed(RefreshToken token)
47+
=> FromToken(token, RefreshTokenValidationState.Consumed);
48+
49+
public static RefreshTokenValidationResult Valid(RefreshToken token, string tokenHash)
50+
=> new()
51+
{
52+
State = RefreshTokenValidationState.Valid,
53+
54+
Tenant = token.Tenant,
55+
UserKey = token.UserKey,
56+
SessionId = token.SessionId,
57+
ChainId = token.ChainId,
58+
59+
TokenHash = tokenHash,
60+
ExpiresAt = token.ExpiresAt,
61+
ReplacedByTokenHash = token.ReplacedByTokenHash
62+
};
63+
64+
private static RefreshTokenValidationResult FromToken(RefreshToken token, RefreshTokenValidationState state)
65+
=> new()
66+
{
67+
State = state,
68+
69+
Tenant = token.Tenant,
70+
UserKey = token.UserKey,
71+
SessionId = token.SessionId,
72+
ChainId = token.ChainId,
73+
74+
TokenHash = token.TokenHash,
75+
ExpiresAt = token.ExpiresAt,
76+
ReplacedByTokenHash = token.ReplacedByTokenHash,
77+
78+
ConsumedAt = token.RevokedAt
79+
};
80+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace CodeBeam.UltimateAuth.Core.Contracts;
2+
3+
public enum RefreshTokenValidationState
4+
{
5+
Valid = 0,
6+
NotFound = 10,
7+
Expired = 20,
8+
Consumed = 30,
9+
Invalid = 40
10+
}

‎src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,24 +149,29 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n
149149
securityVersion: SecurityVersion + 1);
150150
}
151151

152-
/// <summary>
153-
/// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached.
154-
/// If already locked, may extend lock depending on extendLock.
155-
/// </summary>
156-
public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true)
152+
public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, TimeSpan failureWindow, bool extendLock = true)
157153
{
158154
if (threshold < 0)
159155
throw new UAuthValidationException(nameof(threshold));
160156

161157
var effectiveFailedAttempts = FailedAttempts;
162158
var effectiveLockedUntil = LockedUntil;
163159

160+
// Existing lock expired.
164161
if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value)
165162
{
166163
effectiveFailedAttempts = 0;
167164
effectiveLockedUntil = null;
168165
}
169166

167+
// Previous failure sequence expired.
168+
if (failureWindow > TimeSpan.Zero &&
169+
LastFailedAt is DateTimeOffset lastFailedAt &&
170+
now - lastFailedAt > failureWindow)
171+
{
172+
effectiveFailedAttempts = 0;
173+
}
174+
170175
var nextCount = effectiveFailedAttempts + 1;
171176

172177
DateTimeOffset? nextLockedUntil = effectiveLockedUntil;
@@ -203,7 +208,13 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres
203208
/// Registers a successful authentication: clears failures and lock.
204209
/// </summary>
205210
public AuthenticationSecurityState RegisterSuccess()
206-
=> new AuthenticationSecurityState(
211+
{
212+
if (FailedAttempts == 0 && LastFailedAt is null && LockedUntil is null)
213+
{
214+
return this;
215+
}
216+
217+
return new AuthenticationSecurityState(
207218
Id,
208219
Tenant,
209220
UserKey,
@@ -219,6 +230,7 @@ public AuthenticationSecurityState RegisterSuccess()
219230
ResetTokenHash,
220231
ResetAttempts,
221232
securityVersion: SecurityVersion + 1);
233+
}
222234

223235
/// <summary>
224236
/// Admin/system unlock: clears lock and failures.

‎src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs‎

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,33 @@ public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, IToken
1717
public async Task<RefreshTokenValidationResult> ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default)
1818
{
1919
var store = _storeFactory.Create(context.Tenant);
20+
2021
var hash = _hasher.Hash(context.RefreshToken);
22+
2123
var stored = await store.FindByHashAsync(hash, ct);
2224

2325
if (stored is null)
24-
return RefreshTokenValidationResult.Invalid();
25-
26-
if (stored.IsRevoked)
27-
return RefreshTokenValidationResult.ReuseDetected(
28-
tenant: stored.Tenant,
29-
sessionId: stored.SessionId,
30-
chainId: stored.ChainId,
31-
userKey: stored.UserKey);
26+
return RefreshTokenValidationResult.NotFound();
3227

3328
if (stored.IsExpired(context.Now))
29+
return RefreshTokenValidationResult.Expired(stored);
30+
31+
if (context.ExpectedSessionId.HasValue &&
32+
stored.SessionId != context.ExpectedSessionId.Value)
3433
{
35-
await store.RevokeAsync(hash, context.Now, null, ct);
3634
return RefreshTokenValidationResult.Invalid();
3735
}
3836

39-
if (context.ExpectedSessionId.HasValue && stored.SessionId != context.ExpectedSessionId)
37+
if (stored.IsRevoked)
4038
{
39+
if (stored.ReplacedByTokenHash is not null)
40+
{
41+
return RefreshTokenValidationResult.Consumed(stored);
42+
}
43+
4144
return RefreshTokenValidationResult.Invalid();
4245
}
4346

44-
// TODO: Add device binding
45-
// if (context.Device != null && !stored.MatchesDevice(context.Device))
46-
// return Invalid();
47-
48-
return RefreshTokenValidationResult.Valid(
49-
tenant: stored.Tenant,
50-
stored.UserKey,
51-
stored.SessionId,
52-
hash,
53-
stored.ChainId);
47+
return RefreshTokenValidationResult.Valid(stored, hash);
5448
}
5549
}

‎src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ public sealed class UAuthTokenOptions
3838
/// </summary>
3939
public int OpaqueIdBytes { get; set; } = 32;
4040

41+
/// <summary>
42+
/// Defines the time window after a refresh token has been consumed during which another use of the same token may be treated as a
43+
/// concurrent duplicate request rather than a confirmed replay.
44+
///
45+
/// The duplicate request is still rejected; this option only controls whether the token family is revoked as a replay response.
46+
/// </summary>
47+
public TimeSpan RefreshTokenConcurrentRequestWindow { get; set; } = TimeSpan.FromSeconds(2);
48+
4149
/// <summary>
4250
/// Value assigned to the JWT "iss" (issuer) claim.
4351
/// Identifies the authority that issued the token.
@@ -73,6 +81,7 @@ public sealed class UAuthTokenOptions
7381
Issuer = Issuer,
7482
Audience = Audience,
7583
AddJwtIdClaim = AddJwtIdClaim,
76-
KeyId = KeyId
84+
KeyId = KeyId,
85+
RefreshTokenConcurrentRequestWindow = RefreshTokenConcurrentRequestWindow
7786
};
7887
}

0 commit comments

Comments
 (0)