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 @@ -8,6 +8,8 @@ public interface IAuthenticationSecurityManager
{
Task<AuthenticationSecurityState> GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default);
Task<AuthenticationSecurityState> GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default);
Task<AuthenticationSecurityState> MutateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, Func<AuthenticationSecurityState, AuthenticationSecurityState> mutation, CancellationToken ct = default);
Task<AuthenticationSecurityState> MutateAccountAsync(TenantKey tenant, UserKey userKey, Func<AuthenticationSecurityState, AuthenticationSecurityState> mutation, CancellationToken ct = default);
Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default);
Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface IRefreshTokenStore

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

Task<bool> TryConsumeAsync(string tokenHash, DateTimeOffset consumedAt, string replacedByTokenHash, CancellationToken ct = default);

Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default);

Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public interface ISessionStore
Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default);
Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default);

Task<UAuthSessionRoot?> GetRootByUserAsync(UserKey userKey, CancellationToken ct = default);
//Task<UAuthSessionRoot?> GetRootByUserAsync(UserKey userKey, CancellationToken ct = default);
Task<UAuthSessionRoot?> GetActiveRootByUserAsync(UserKey userKey, CancellationToken ct = default);
Task<UAuthSessionRoot?> GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default);
Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default);
Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,59 +5,76 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;

public sealed record RefreshTokenValidationResult
{
public bool IsValid { get; init; }
public bool IsReuseDetected { get; init; }
public RefreshTokenValidationState State { get; init; }

public bool IsValid => State == RefreshTokenValidationState.Valid;

public string? TokenHash { get; init; }

public TenantKey Tenant { get; init; }

public UserKey? UserKey { get; init; }

public AuthSessionId? SessionId { get; init; }

public SessionChainId? ChainId { get; init; }

public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset? ConsumedAt { get; init; }

public string? ReplacedByTokenHash { get; init; }

private RefreshTokenValidationResult() { }
private RefreshTokenValidationResult()
{
}

public static RefreshTokenValidationResult Invalid()
=> new()
{
IsValid = false,
IsReuseDetected = false
State = RefreshTokenValidationState.Invalid
};

public static RefreshTokenValidationResult ReuseDetected(
TenantKey tenant,
AuthSessionId? sessionId = null,
string? tokenHash = null,
SessionChainId? chainId = null,
UserKey? userKey = default)
=> new()
{
IsValid = false,
IsReuseDetected = true,
Tenant = tenant,
SessionId = sessionId,
TokenHash = tokenHash,
ChainId = chainId,
UserKey = userKey,
};

public static RefreshTokenValidationResult Valid(
TenantKey tenant,
UserKey userKey,
AuthSessionId sessionId,
string? tokenHash,
SessionChainId? chainId = null)
=> new()
{
IsValid = true,
IsReuseDetected = false,
Tenant = tenant,
UserKey = userKey,
SessionId = sessionId,
ChainId = chainId,
TokenHash = tokenHash
};
}
public static RefreshTokenValidationResult NotFound()
=> new()
{
State = RefreshTokenValidationState.NotFound
};

public static RefreshTokenValidationResult Expired(RefreshToken token)
=> FromToken(token, RefreshTokenValidationState.Expired);

public static RefreshTokenValidationResult Consumed(RefreshToken token)
=> FromToken(token, RefreshTokenValidationState.Consumed);

public static RefreshTokenValidationResult Valid(RefreshToken token, string tokenHash)
=> new()
{
State = RefreshTokenValidationState.Valid,

Tenant = token.Tenant,
UserKey = token.UserKey,
SessionId = token.SessionId,
ChainId = token.ChainId,

TokenHash = tokenHash,
ExpiresAt = token.ExpiresAt,
ReplacedByTokenHash = token.ReplacedByTokenHash
};

private static RefreshTokenValidationResult FromToken(RefreshToken token, RefreshTokenValidationState state)
=> new()
{
State = state,

Tenant = token.Tenant,
UserKey = token.UserKey,
SessionId = token.SessionId,
ChainId = token.ChainId,

TokenHash = token.TokenHash,
ExpiresAt = token.ExpiresAt,
ReplacedByTokenHash = token.ReplacedByTokenHash,

ConsumedAt = token.RevokedAt
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CodeBeam.UltimateAuth.Core.Contracts;

public enum RefreshTokenValidationState
{
Valid = 0,
NotFound = 10,
Expired = 20,
Consumed = 30,
Invalid = 40
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,29 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n
securityVersion: SecurityVersion + 1);
}

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

var effectiveFailedAttempts = FailedAttempts;
var effectiveLockedUntil = LockedUntil;

// Existing lock expired.
if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value)
{
effectiveFailedAttempts = 0;
effectiveLockedUntil = null;
}

// Previous failure sequence expired.
if (failureWindow > TimeSpan.Zero &&
LastFailedAt is DateTimeOffset lastFailedAt &&
now - lastFailedAt > failureWindow)
{
effectiveFailedAttempts = 0;
}

var nextCount = effectiveFailedAttempts + 1;

DateTimeOffset? nextLockedUntil = effectiveLockedUntil;
Expand Down Expand Up @@ -203,7 +208,13 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres
/// Registers a successful authentication: clears failures and lock.
/// </summary>
public AuthenticationSecurityState RegisterSuccess()
=> new AuthenticationSecurityState(
{
if (FailedAttempts == 0 && LastFailedAt is null && LockedUntil is null)
{
return this;
}

return new AuthenticationSecurityState(
Id,
Tenant,
UserKey,
Expand All @@ -219,6 +230,7 @@ public AuthenticationSecurityState RegisterSuccess()
ResetTokenHash,
ResetAttempts,
securityVersion: SecurityVersion + 1);
}

/// <summary>
/// Admin/system unlock: clears lock and failures.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,39 +17,33 @@ public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, IToken
public async Task<RefreshTokenValidationResult> ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default)
{
var store = _storeFactory.Create(context.Tenant);

var hash = _hasher.Hash(context.RefreshToken);

var stored = await store.FindByHashAsync(hash, ct);

if (stored is null)
return RefreshTokenValidationResult.Invalid();

if (stored.IsRevoked)
return RefreshTokenValidationResult.ReuseDetected(
tenant: stored.Tenant,
sessionId: stored.SessionId,
chainId: stored.ChainId,
userKey: stored.UserKey);
return RefreshTokenValidationResult.NotFound();

if (stored.IsExpired(context.Now))
return RefreshTokenValidationResult.Expired(stored);

if (context.ExpectedSessionId.HasValue &&
stored.SessionId != context.ExpectedSessionId.Value)
{
await store.RevokeAsync(hash, context.Now, null, ct);
return RefreshTokenValidationResult.Invalid();
}

if (context.ExpectedSessionId.HasValue && stored.SessionId != context.ExpectedSessionId)
if (stored.IsRevoked)
{
if (stored.ReplacedByTokenHash is not null)
{
return RefreshTokenValidationResult.Consumed(stored);
}

return RefreshTokenValidationResult.Invalid();
}

// TODO: Add device binding
// if (context.Device != null && !stored.MatchesDevice(context.Device))
// return Invalid();

return RefreshTokenValidationResult.Valid(
tenant: stored.Tenant,
stored.UserKey,
stored.SessionId,
hash,
stored.ChainId);
return RefreshTokenValidationResult.Valid(stored, hash);
}
}
11 changes: 10 additions & 1 deletion src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public sealed class UAuthTokenOptions
/// </summary>
public int OpaqueIdBytes { get; set; } = 32;

/// <summary>
/// Defines the time window after a refresh token has been consumed during which another use of the same token may be treated as a
/// concurrent duplicate request rather than a confirmed replay.
///
/// The duplicate request is still rejected; this option only controls whether the token family is revoked as a replay response.
/// </summary>
public TimeSpan RefreshTokenConcurrentRequestWindow { get; set; } = TimeSpan.FromSeconds(2);

/// <summary>
/// Value assigned to the JWT "iss" (issuer) claim.
/// Identifies the authority that issued the token.
Expand Down Expand Up @@ -73,6 +81,7 @@ public sealed class UAuthTokenOptions
Issuer = Issuer,
Audience = Audience,
AddJwtIdClaim = AddJwtIdClaim,
KeyId = KeyId
KeyId = KeyId,
RefreshTokenConcurrentRequestWindow = RefreshTokenConcurrentRequestWindow
};
}
Loading
Loading