diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs index 1e12f065..3a1fa76a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs @@ -8,6 +8,8 @@ public interface IAuthenticationSecurityManager { Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default); + Task MutateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, Func mutation, CancellationToken ct = default); + Task MutateAccountAsync(TenantKey tenant, UserKey userKey, Func 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); } diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs index 095beb5e..a9d487cd 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -11,6 +11,8 @@ public interface IRefreshTokenStore Task FindByHashAsync(string tokenHash, CancellationToken ct = default); + Task 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); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs index fc29ea73..37f393b4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -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 GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); + //Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); + Task GetActiveRootByUserAsync(UserKey userKey, CancellationToken ct = default); Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default); Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default); Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs index 8ab8977b..686a392b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -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 + }; +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationState.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationState.cs new file mode 100644 index 00000000..38167d90 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationState.cs @@ -0,0 +1,10 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum RefreshTokenValidationState +{ + Valid = 0, + NotFound = 10, + Expired = 20, + Consumed = 30, + Invalid = 40 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs index d30489d9..0b7444e3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -149,11 +149,7 @@ public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset n securityVersion: SecurityVersion + 1); } - /// - /// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached. - /// If already locked, may extend lock depending on extendLock. - /// - 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)); @@ -161,12 +157,21 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres 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; @@ -203,7 +208,13 @@ public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int thres /// Registers a successful authentication: clears failures and lock. /// public AuthenticationSecurityState RegisterSuccess() - => new AuthenticationSecurityState( + { + if (FailedAttempts == 0 && LastFailedAt is null && LockedUntil is null) + { + return this; + } + + return new AuthenticationSecurityState( Id, Tenant, UserKey, @@ -219,6 +230,7 @@ public AuthenticationSecurityState RegisterSuccess() ResetTokenHash, ResetAttempts, securityVersion: SecurityVersion + 1); + } /// /// Admin/system unlock: clears lock and failures. diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs index 349dd8ab..0641d885 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -17,39 +17,33 @@ public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, IToken public async Task 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); } } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs index 7d9aeacd..25725ac3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -38,6 +38,14 @@ public sealed class UAuthTokenOptions /// public int OpaqueIdBytes { get; set; } = 32; + /// + /// 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. + /// + public TimeSpan RefreshTokenConcurrentRequestWindow { get; set; } = TimeSpan.FromSeconds(2); + /// /// Value assigned to the JWT "iss" (issuer) claim. /// Identifies the authority that issued the token. @@ -73,6 +81,7 @@ public sealed class UAuthTokenOptions Issuer = Issuer, Audience = Audience, AddJwtIdClaim = AddJwtIdClaim, - KeyId = KeyId + KeyId = KeyId, + RefreshTokenConcurrentRequestWindow = RefreshTokenConcurrentRequestWindow }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs index f64ebecd..cb630b62 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Server.Options; @@ -27,8 +28,21 @@ public async Task GetOrCreateAccountAsync(TenantKey return state; var created = AuthenticationSecurityState.CreateAccount(tenant, userKey); - await store.AddAsync(created, ct); - return created; + + try + { + await store.AddAsync(created, ct); + return created; + } + catch (UAuthConflictException) + { + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); + + if (existing is not null) + return existing; + + throw; + } } public async Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default) @@ -42,8 +56,85 @@ public async Task GetOrCreateFactorAsync(TenantKey return state; var created = AuthenticationSecurityState.CreateFactor(tenant, userKey, type); - await store.AddAsync(created, ct); - return created; + + try + { + await store.AddAsync(created, ct); + return created; + } + catch (UAuthConflictException) + { + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Factor, type, ct); + + if (existing is not null) + return existing; + + throw; + } + } + + public async Task MutateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, Func mutation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutation); + + const int maxAttempts = 5; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + + var current = await GetOrCreateFactorAsync(tenant, userKey, type, ct); + + var updated = mutation(current); + + if (ReferenceEquals(updated, current)) + return current; + + try + { + await UpdateAsync(updated, current.SecurityVersion, ct); + + return updated; + } + catch (UAuthConflictException) when (attempt < maxAttempts - 1) + { + // State changed after it was read. + // Reload and reapply the domain mutation. + } + } + + throw new InvalidOperationException("Unreachable."); + } + + public async Task MutateAccountAsync(TenantKey tenant, UserKey userKey, Func mutation, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutation); + + const int maxAttempts = 5; + + for (var attempt = 0; attempt < maxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + + var current = await GetOrCreateAccountAsync(tenant, userKey, ct); + + var updated = mutation(current); + + if (ReferenceEquals(updated, current)) + return current; + + try + { + await UpdateAsync(updated, current.SecurityVersion, ct); + + return updated; + } + catch (UAuthConflictException) when (attempt < maxAttempts - 1) + { + } + } + + throw new InvalidOperationException("Unreachable."); } public Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index b9cf26dc..20cf8a70 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -151,7 +151,8 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol // Tenant Resolution services.TryAddSingleton(sp => { - var opts = sp.GetRequiredService>().Value; + var options = sp.GetRequiredService>().Value; + var opts = options.MultiTenant; var resolvers = new List(); @@ -438,7 +439,7 @@ private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceC services.TryAddScoped(); services.TryAddSingleton(sp => { - var opts = sp.GetRequiredService>().Value; + var opts = sp.GetRequiredService>().Value.MultiTenant; var resolvers = new List(); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs index 29a97238..fe13ec65 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Server.Flows; @@ -8,6 +9,13 @@ namespace CodeBeam.UltimateAuth.Server.Flows; /// public sealed class LoginAuthority : ILoginAuthority { + private readonly IClock _clock; + + public LoginAuthority(IClock clock) + { + _clock = clock; + } + public LoginDecision Decide(LoginDecisionContext context) { if (!context.UserExists || context.UserKey is null) @@ -18,7 +26,7 @@ public LoginDecision Decide(LoginDecisionContext context) var state = context.SecurityState; if (state is not null) { - if (state.IsLocked(DateTimeOffset.UtcNow)) + if (state.IsLocked(_clock.UtcNow)) return LoginDecision.Deny(AuthFailureReason.LockedOut); if (state.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 1b353d97..5c215621 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -165,9 +165,17 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (!loginExecution.SuppressFailureAttempt) { - var version = factorState.SecurityVersion; - factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); - await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + factorState = await _authenticationSecurityManager.MutateFactorAsync( + flow.Tenant, + userKey.Value, + request.Factor, + state => state.RegisterFailure( + now, + _options.Login.MaxFailedAttempts, + _options.Login.LockoutDuration, + _options.Login.FailureWindow, + _options.Login.ExtendLockOnFailure), + ct); } if (_options.Login.IncludeFailureDetails) @@ -217,9 +225,12 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (!loginExecution.SuppressSuccessReset && factorState is not null) { - var version = factorState.SecurityVersion; - factorState = factorState.RegisterSuccess(); - await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + factorState = await _authenticationSecurityManager.MutateFactorAsync( + flow.Tenant, + userKey.Value, + request.Factor, + state => state.RegisterSuccess(), + ct); } var claims = await _claimsProvider.GetClaimsAsync(flow.Tenant, userKey.Value, ct); @@ -259,8 +270,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req }; } - await _events.DispatchAsync( - new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); + await _events.DispatchAsync(new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); return LoginResult.Success(issuedSession.Session.SessionId, tokens); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 0fb5e56c..186ed176 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -51,17 +51,13 @@ public async Task IssueSessionAsync(SessionIssuanceContext contex await kernel.ExecuteAsync(async _ => { - var root = await kernel.GetRootByUserAsync(context.UserKey); + var root = await kernel.GetActiveRootByUserAsync(context.UserKey); if (root is null) { root = UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); await kernel.CreateRootAsync(root); } - else if (root.IsRevoked) - { - throw new UAuthValidationException("Session root revoked."); - } UAuthSessionChain chain; @@ -97,11 +93,11 @@ await kernel.ExecuteAsync(async _ => if (chainState != SessionState.Active) throw new UAuthValidationException("Chain is not active."); - //if (chain.IsRevoked) - // throw new UAuthValidationException("Chain revoked."); - if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant) throw new UAuthValidationException("Invalid chain ownership."); + + if (chain.RootId != root.RootId) + throw new UAuthValidationException("Chain does not belong to the active session root."); } else { @@ -164,6 +160,7 @@ await kernel.ExecuteAsync(async _ => if (issued == null) throw new InvalidCastException("Issue failed."); + return issued; } @@ -188,19 +185,20 @@ public async Task RotateSessionAsync(SessionRotationContext conte await kernel.ExecuteAsync(async _ => { - var root = await kernel.GetRootByUserAsync(context.UserKey); + var root = await kernel.GetActiveRootByUserAsync(context.UserKey); + if (root == null) throw new SecurityException("Session root not found"); - if (root.IsRevoked) - throw new SecurityException("Session root is revoked"); - var oldSession = await kernel.GetSessionAsync(context.CurrentSessionId) ?? throw new SecurityException("Session not found"); if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) throw new SecurityException("Session is not valid"); + if (oldSession.Tenant != context.Tenant || oldSession.UserKey != context.UserKey) + throw new SecurityException("Session does not belong to the current user/tenant."); + if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) throw new SecurityException("Security version mismatch"); @@ -213,6 +211,11 @@ await kernel.ExecuteAsync(async _ => if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) throw new SecurityException("Chain does not belong to the current user/tenant."); + if (chain.RootId != root.RootId) + { + throw new SecurityException("Chain does not belong to the active session root."); + } + var newSessionUnbound = UAuthSession.Create( sessionId: newSessionId, tenant: context.Tenant, @@ -289,14 +292,10 @@ await kernel.ExecuteAsync(async _ => public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) { var kernel = _storeFactory.Create(tenant); - await kernel.ExecuteAsync(async _ => - { - var root = await kernel.GetRootByUserAsync(userKey); - if (root is null) - return; - await kernel.RevokeRootCascadeAsync(userKey, at); - }, ct); + await kernel.ExecuteAsync( + _ => kernel.RevokeRootCascadeAsync(userKey, at), + ct); } public async Task GetChainIdBySessionAsync(TenantKey tenant, AuthSessionId sessionId, CancellationToken ct = default) diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs index 6c2a97cf..7a22310c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -16,12 +17,12 @@ public TenantMiddleware(RequestDelegate next) _next = next; } - public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) + public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) { var opts = options.Value; TenantResolutionResult resolution; - if (!opts.Enabled) + if (!opts.MultiTenant.Enabled) { context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant(); await _next(context); diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs index 54d80e47..440942ec 100644 --- a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -8,9 +9,9 @@ namespace CodeBeam.UltimateAuth.Server.MultiTenancy; public sealed class UAuthTenantResolver : ITenantResolver { private readonly ITenantIdResolver _idResolver; - private readonly UAuthMultiTenantOptions _options; + private readonly UAuthServerOptions _options; - public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options) + public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options) { _idResolver = idResolver; _options = options.Value; @@ -25,7 +26,7 @@ public async Task ResolveAsync(HttpContext context) if (string.IsNullOrWhiteSpace(raw)) return TenantResolutionResult.NotResolved(); - var normalized = _options.NormalizeToLowercase + var normalized = _options.MultiTenant.NormalizeToLowercase ? raw.Trim().ToLowerInvariant() : raw.Trim(); diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs index e1245421..d64bb39b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -25,6 +25,7 @@ public sealed class CredentialResponseOptions internal CredentialResponseOptions Clone() => new() { + Kind = Kind, Mode = Mode, Name = Name, HeaderFormat = HeaderFormat, diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs index 2fb51a20..5350b4a1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs @@ -1,8 +1,19 @@ -namespace CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; public class UAuthResourceApiOptions { public string UAuthHubBaseUrl { get; set; } = default!; public HashSet AllowedClientOrigins { get; set; } = new(); public string CorsPolicyName { get; set; } = "UAuthResource"; + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + internal UAuthResourceApiOptions Clone() => new() + { + UAuthHubBaseUrl = UAuthHubBaseUrl, + AllowedClientOrigins = new HashSet(AllowedClientOrigins), + CorsPolicyName = CorsPolicyName, + MultiTenant = MultiTenant.Clone() + }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs index 129b62b1..f6107b7d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs @@ -9,6 +9,11 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) var errors = new List(); var tokens = options.Token; + if (options.Token.RefreshTokenConcurrentRequestWindow < TimeSpan.Zero) + { + errors.Add("Token.RefreshTokenConcurrentRequestWindow cannot be negative."); + } + if (!tokens.IssueJwt && !tokens.IssueOpaque) errors.Add("Token: At least one of IssueJwt or IssueOpaque must be enabled."); @@ -39,7 +44,7 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); if (tokens.OpaqueIdBytes > 128) - errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + errors.Add("Token.OpaqueIdBytes must not exceed 128 bytes."); } return errors.Count == 0 diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index ad04b6bf..75f77923 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -35,26 +35,30 @@ public async Task RotateAsync(AuthFlowContext flo }, ct); - if (validation.IsReuseDetected) + if (validation.State == RefreshTokenValidationState.Consumed) { - var store1 = _storeFactory.Create(validation.Tenant); + var concurrencyWindow = flow.OriginalOptions.Token.RefreshTokenConcurrentRequestWindow; - if (validation.ChainId is not null) + if (IsLikelyConcurrentRequest(validation, context.Now, concurrencyWindow)) { - await store1.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); - } - else if (validation.SessionId is not null) - { - await store1.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; } - return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + await HandleReplayAsync(validation, context.Now, ct); + + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; } if (!validation.IsValid) - return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; - - var store = _storeFactory.Create(validation.Tenant); + { + return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() }; + } if (validation.UserKey is not UserKey userKey) throw new UAuthValidationException("Validated refresh token does not contain a UserKey."); @@ -62,9 +66,11 @@ public async Task RotateAsync(AuthFlowContext flo if (validation.SessionId is not AuthSessionId sessionId) throw new UAuthValidationException("Validated refresh token does not contain a SessionId."); - if (validation.TokenHash == null) + if (validation.TokenHash is null) throw new UAuthValidationException("Validated refresh token does not contain a hashed token."); + var store = _storeFactory.Create(validation.Tenant); + var tokenContext = new TokenIssuanceContext { Tenant = flow.OriginalOptions.MultiTenant.Enabled @@ -72,48 +78,101 @@ public async Task RotateAsync(AuthFlowContext flo : TenantKey.Single, UserKey = userKey, - SessionId = validation.SessionId, + SessionId = sessionId, ChainId = validation.ChainId }; - var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + // Generate candidate replacement refresh token. + // Do not persist it yet. var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.DoNotPersist, ct); if (refreshToken is null) - return new RefreshTokenRotationExecution + { + return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() }; + } + + // Only one concurrent request is allowed to consume + // the current refresh token. + var consumed = await store.ExecuteAsync( + async ct2 => { - Result = RefreshTokenRotationResult.Failed() - }; + var acquired = await store.TryConsumeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); - // Generate the replacement token without persisting it. - // Revoke the current token and persist its replacement atomically. - await store.ExecuteAsync(async ct2 => - { - await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); + if (!acquired) + return false; - var stored = RefreshToken.Create( - tokenId: TokenId.New(), - tokenHash: refreshToken.TokenHash, - tenant: validation.Tenant, - userKey: userKey, - sessionId: sessionId, - chainId: validation.ChainId, - createdAt: context.Now, - expiresAt: refreshToken.ExpiresAt - ); + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: refreshToken.TokenHash, + tenant: validation.Tenant, + userKey: userKey, + sessionId: sessionId, + chainId: validation.ChainId, + createdAt: context.Now, + expiresAt: refreshToken.ExpiresAt); - await store.StoreAsync(stored, ct2); + await store.StoreAsync(stored, ct2); + + return true; + }, + ct); - }, ct); + // Another concurrent request consumed this token first. + if (!consumed) + { + return new RefreshTokenRotationExecution { Result = RefreshTokenRotationResult.Failed() }; + } + // Only the winning request needs an access token. + var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); return new RefreshTokenRotationExecution { Tenant = validation.Tenant, - UserKey = validation.UserKey, - SessionId = validation.SessionId, + UserKey = userKey, + SessionId = sessionId, ChainId = validation.ChainId, + Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) }; } + + private async Task HandleReplayAsync(RefreshTokenValidationResult validation, DateTimeOffset now, CancellationToken ct) + { + var store = _storeFactory.Create(validation.Tenant); + + await store.ExecuteAsync( + async ct2 => + { + if (validation.ChainId is SessionChainId chainId) + { + await store.RevokeByChainAsync(chainId, now, ct2); + + return; + } + + if (validation.SessionId is AuthSessionId sessionId) + { + await store.RevokeBySessionAsync(sessionId, now, ct2); + } + }, + ct); + } + + private static bool IsLikelyConcurrentRequest(RefreshTokenValidationResult validation, DateTimeOffset now, TimeSpan window) + { + if (validation.State != RefreshTokenValidationState.Consumed) + return false; + + if (validation.ConsumedAt is not DateTimeOffset consumedAt) + return false; + + if (validation.ReplacedByTokenHash is null) + return false; + + var elapsed = now - consumedAt; + + return elapsed >= TimeSpan.Zero && + elapsed <= window; + } } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index 27f42a3a..c993e957 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -155,17 +155,16 @@ public async Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, var store = _storeFactory.Create(context.ResourceTenant); var now = _clock.UtcNow; - var session = await store.GetSessionAsync(sessionId) - ?? throw new InvalidOperationException("session_not_found"); + var session = await store.GetSessionAsync(sessionId, innerCt); - if (session.UserKey != userKey) - throw new UnauthorizedAccessException(); + if (session is null || session.UserKey != userKey) + throw new UAuthNotFoundException("session_not_found"); var expected = session.Version; var revoked = session.Revoke(now); await store.ExecuteAsync(async innerCt2 => { - await store.SaveSessionAsync(revoked, expected); + await store.SaveSessionAsync(revoked, expected, innerCt2); }); }); @@ -226,16 +225,26 @@ await store.ExecuteAsync(async innerCt2 => { await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default) + public async Task LogoutDeviceAsync(AccessContext context, SessionChainId chainId, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => { - var isCurrent = context.ActorChainId == currentChainId; var store = _storeFactory.Create(context.ResourceTenant); + + var targetUserKey = context.GetTargetUserKey(); + + var chain = await store.GetChainAsync(chainId, innerCt) + ?? throw new UAuthNotFoundException("chain_not_found"); + + if (chain.UserKey != targetUserKey) + throw new UAuthNotFoundException("chain_not_found"); + + var isCurrent = context.ActorChainId == chainId; var now = _clock.UtcNow; - await store.ExecuteAsync(async innerCt2 => { - await store.LogoutChainAsync(currentChainId, now, innerCt2); + await store.ExecuteAsync(async innerCt2 => + { + await store.LogoutChainAsync(chainId, now, innerCt2); }); return new RevokeResult diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index 40aab932..fa97d6c1 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -53,7 +53,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (chain.Tenant != context.Tenant) return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); - var root = await kernel.GetRootByUserAsync(session.UserKey, ct); + var root = await kernel.GetActiveRootByUserAsync(session.UserKey, ct); if (root is null || root.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId); diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs index e6d98900..3e0a462f 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs @@ -45,7 +45,8 @@ private static void ConfigureRoots(ModelBuilder b) e.Property(x => x.SecurityVersion) .IsRequired(); - e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.UserKey }); e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); }); } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index bcb56759..40a52a4a 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -237,6 +237,16 @@ public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChai if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); + var preservedChainExists = await DbSetChain.AnyAsync( + x => + x.Tenant == _tenant && + x.UserKey == user && + x.ChainId == keepChain, + ct); + + if (!preservedChainExists) + throw new UAuthNotFoundException("session_chain_not_found"); + var chains = await DbSetChain .Where(x => x.Tenant == _tenant && x.UserKey == user && x.ChainId != keepChain) .ToListAsync(ct); @@ -502,12 +512,29 @@ public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId projection.Version++; } - public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + public async Task GetActiveRootByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var rootProjection = await DbSetRoot.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); - return rootProjection?.ToDomain(); + var local = DbSetRoot.Local + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null); + + if (local is not null) + return local.ToDomain(); + + var projection = await DbSetRoot + .AsNoTracking() + .SingleOrDefaultAsync( + x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null, + ct); + + return projection?.ToDomain(); } public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) @@ -515,16 +542,25 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can ct.ThrowIfCancellationRequested(); if (root.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); - var projection = await DbSetRoot - .SingleOrDefaultAsync(x => + var projection = DbSetRoot.Local + .SingleOrDefault(x => x.Tenant == _tenant && - x.UserKey == root.UserKey, - ct); + x.RootId == root.RootId); + + if (projection is null) + { + projection = await DbSetRoot + .SingleOrDefaultAsync( + x => + x.Tenant == _tenant && + x.RootId == root.RootId, + ct); + } if (projection is null) throw new UAuthNotFoundException("root_not_found"); @@ -541,35 +577,58 @@ public async Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = ct.ThrowIfCancellationRequested(); if (root.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); if (root.Version != 0) - throw new InvalidOperationException("New root must have version 0."); + throw new UAuthValidationException("New root must have version 0."); - var exists = DbSetRoot.Local.Any(x => - x.Tenant == _tenant && - x.UserKey == root.UserKey); + if (root.IsRevoked) + throw new UAuthValidationException("New root cannot already be revoked."); - if (!exists) + var rootIdExists = + DbSetRoot.Local.Any(x => + x.Tenant == _tenant && + x.RootId == root.RootId); + + if (!rootIdExists) { - exists = await DbSetRoot + rootIdExists = await DbSetRoot .AsNoTracking() .AnyAsync( x => x.Tenant == _tenant && - x.UserKey == root.UserKey, + x.RootId == root.RootId, ct); } - if (exists) - throw new UAuthConcurrencyException("root_already_exists"); + if (rootIdExists) + throw new UAuthConflictException("session_root_already_exists"); + + var activeRootExists = + DbSetRoot.Local.Any(x => + x.Tenant == _tenant && + x.UserKey == root.UserKey && + x.RevokedAt == null); - var projection = root.ToProjection(); + if (!activeRootExists) + { + activeRootExists = await DbSetRoot + .AsNoTracking() + .AnyAsync( + x => + x.Tenant == _tenant && + x.UserKey == root.UserKey && + x.RevokedAt == null, + ct); + } + + if (activeRootExists) + throw new UAuthConflictException("active_session_root_already_exists"); - DbSetRoot.Add(projection); + DbSetRoot.Add(root.ToProjection()); } public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) @@ -579,13 +638,28 @@ public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, Cancellati if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); - var projection = await DbSetRoot - .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + var projection = DbSetRoot.Local + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null); - if (projection is null || projection.RevokedAt is not null) + if (projection is null) + { + projection = await DbSetRoot + .SingleOrDefaultAsync( + x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null, + ct); + } + + if (projection is null) return; var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); projection.Version++; } @@ -720,50 +794,73 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can ct.ThrowIfCancellationRequested(); if (!_inExecution) - throw new InvalidOperationException("Must be called inside ExecuteAsync"); + throw new InvalidOperationException( + "Must be called inside ExecuteAsync"); - var rootProjection = await DbSetRoot - .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + var rootProjection = DbSetRoot.Local + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null); + + if (rootProjection is null) + { + rootProjection = await DbSetRoot + .SingleOrDefaultAsync( + x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null, + ct); + } if (rootProjection is null) return; var chainProjections = await DbSetChain - .Where(x => x.Tenant == _tenant && x.UserKey == userKey) + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RootId == rootProjection.RootId) .ToListAsync(ct); - foreach (var chainProjection in chainProjections) - { - var sessions = await DbSetSession - .Where(x => x.Tenant == _tenant && x.ChainId == chainProjection.ChainId) - .ToListAsync(ct); - - foreach (var sessionProjection in sessions) - { - if (sessionProjection.RevokedAt is not null) - continue; + var chainIds = chainProjections + .Select(x => x.ChainId) + .ToList(); - var sessionDomain = sessionProjection.ToDomain().Revoke(at); + var sessionProjections = await DbSetSession + .Where(x => + x.Tenant == _tenant && + chainIds.Contains(x.ChainId)) + .ToListAsync(ct); - sessionDomain.UpdateProjection(sessionProjection); - sessionProjection.Version++; - } + foreach (var sessionProjection in sessionProjections) + { + if (sessionProjection.RevokedAt is not null) + continue; - if (chainProjection.RevokedAt is null) - { - var chainDomain = chainProjection.ToDomain().Revoke(at); + var sessionDomain = + sessionProjection.ToDomain().Revoke(at); - chainDomain.UpdateProjection(chainProjection); - chainProjection.Version++; - } + sessionDomain.UpdateProjection(sessionProjection); + sessionProjection.Version++; } - if (rootProjection.RevokedAt is null) + foreach (var chainProjection in chainProjections) { - var rootDomain = rootProjection.ToDomain().Revoke(at); + if (chainProjection.RevokedAt is not null) + continue; + + var chainDomain = + chainProjection.ToDomain().Revoke(at); - rootDomain.UpdateProjection(rootProjection); - rootProjection.Version++; + chainDomain.UpdateProjection(chainProjection); + chainProjection.Version++; } + + var rootDomain = rootProjection.ToDomain().Revoke(at); + + rootDomain.UpdateProjection(rootProjection); + rootProjection.Version++; } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index c081e412..d18421e3 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -19,7 +19,7 @@ public InMemorySessionStore(TenantKey tenant) private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _chains = new(); - private readonly ConcurrentDictionary<(TenantKey, UserKey), UAuthSessionRoot> _roots = new(); + private readonly ConcurrentDictionary _roots = new(); public async Task ExecuteAsync(Func action, CancellationToken ct = default) { @@ -152,6 +152,13 @@ public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, Dat lock (_lock) { + if (!_chains.TryGetValue(keepChain, out var preservedChain) || + preservedChain.Tenant != _tenant || + preservedChain.UserKey != user) + { + throw new UAuthNotFoundException("session_chain_not_found"); + } + foreach (var (id, chain) in _chains) { if (chain.UserKey != user) @@ -275,16 +282,31 @@ public Task RevokeAllChainsAsync(UserKey user, DateTimeOffset at, CancellationTo return Task.CompletedTask; } - public Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + public Task GetActiveRootByUserAsync(UserKey userKey, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_roots.TryGetValue((_tenant, userKey), out var r) ? r : null); + + lock (_lock) + { + var root = _roots.Values + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + !x.IsRevoked); + + return Task.FromResult(root); + } } public Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - return Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + + return Task.FromResult( + _roots.TryGetValue(rootId, out var root) && + root.Tenant == _tenant + ? root + : null); } public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) @@ -292,15 +314,20 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Cancellat ct.ThrowIfCancellationRequested(); if (root.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); - if (!_roots.TryGetValue((_tenant, root.UserKey), out var current)) - throw new UAuthNotFoundException("root_not_found"); + lock (_lock) + { + if (!_roots.TryGetValue(root.RootId, out var current)) + throw new UAuthNotFoundException("root_not_found"); - if (current.Version != expectedVersion) - throw new UAuthConcurrencyException("root_concurrency_conflict"); + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException( + "root_concurrency_conflict"); + + _roots[root.RootId] = root; + } - _roots[(_tenant, root.UserKey)] = root; return Task.CompletedTask; } @@ -309,17 +336,28 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul ct.ThrowIfCancellationRequested(); if (root.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); + + if (root.Version != 0) + throw new UAuthValidationException("New root must have version 0."); + + if (root.IsRevoked) + throw new UAuthValidationException("New root cannot already be revoked."); lock (_lock) { - if (_roots.ContainsKey((_tenant, root.UserKey))) - throw new UAuthConcurrencyException("root_already_exists"); + if (_roots.ContainsKey(root.RootId)) + throw new UAuthConflictException("session_root_already_exists"); + + var activeRootExists = _roots.Values.Any(x => + x.Tenant == _tenant && + x.UserKey == root.UserKey && + !x.IsRevoked); - if (root.Version != 0) - throw new InvalidOperationException("New root must have version 0."); + if (activeRootExists) + throw new UAuthConflictException("active_session_root_already_exists"); - _roots[(_tenant, root.UserKey)] = root; + _roots[root.RootId] = root; } return Task.CompletedTask; @@ -329,10 +367,20 @@ public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToke { ct.ThrowIfCancellationRequested(); - if (_roots.TryGetValue((_tenant, userKey), out var root)) + lock (_lock) { - _roots[(_tenant, userKey)] = root.Revoke(at); + var root = _roots.Values + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + !x.IsRevoked); + + if (root is null) + return Task.CompletedTask; + + _roots[root.RootId] = root.Revoke(at); } + return Task.CompletedTask; } @@ -486,20 +534,26 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat lock (_lock) { - if (!_roots.TryGetValue((_tenant, userKey), out var root)) + var root = _roots.Values + .SingleOrDefault(x => + x.Tenant == _tenant && + x.UserKey == userKey && + !x.IsRevoked); + + if (root is null) return Task.CompletedTask; var chains = _chains.Values - .Where(c => c.UserKey == userKey && c.Tenant == root.Tenant) + .Where(c => + c.Tenant == _tenant && + c.UserKey == userKey && + c.RootId == root.RootId) .ToList(); foreach (var chain in chains) { if (!chain.IsRevoked) - { - var revokedChain = chain.Revoke(at); - _chains[chain.ChainId] = revokedChain; - } + _chains[chain.ChainId] = chain.Revoke(at); var sessions = _sessions.Values .Where(s => s.ChainId == chain.ChainId) @@ -509,17 +563,13 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat { if (!session.IsRevoked) { - var revokedSession = session.Revoke(at); - _sessions[session.SessionId] = revokedSession; + _sessions[session.SessionId] = + session.Revoke(at); } } } - if (!root.IsRevoked) - { - var revokedRoot = root.Revoke(at); - _roots[(_tenant, userKey)] = revokedRoot; - } + _roots[root.RootId] = root.Revoke(at); } return Task.CompletedTask; diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs index 94bbe0c1..ae4d5a16 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.EntityFrameworkCore; @@ -88,7 +89,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) EnsureTransaction(); if (token.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); DbSet.Add(token.ToProjection()); @@ -109,6 +110,29 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) return p?.ToDomain(); } + public async Task TryConsumeAsync(string tokenHash, DateTimeOffset consumedAt, string replacedByTokenHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + var affected = await DbSet + .Where(x => + x.Tenant == _tenant && + x.TokenHash == tokenHash && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x + .SetProperty( + t => t.RevokedAt, + consumedAt) + .SetProperty( + t => t.ReplacedByTokenHash, + replacedByTokenHash), + ct); + + return affected == 1; + } + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs index a5787f95..e5cb2903 100644 --- a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tokens.InMemory; @@ -50,7 +51,7 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) ct.ThrowIfCancellationRequested(); if (token.Tenant != _tenant) - throw new InvalidOperationException("Tenant mismatch."); + throw new UAuthValidationException("Tenant mismatch."); _tokens[(_tenant, token.TokenHash)] = token; @@ -66,6 +67,23 @@ public Task StoreAsync(RefreshToken token, CancellationToken ct = default) return Task.FromResult(token); } + public Task TryConsumeAsync(string tokenHash, DateTimeOffset consumedAt, string replacedByTokenHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_tokens.TryGetValue((_tenant, tokenHash), out var token)) + { + return Task.FromResult(false); + } + + if (token.IsRevoked) + return Task.FromResult(false); + + _tokens[(_tenant, tokenHash)] = token.Revoke(consumedAt, replacedByTokenHash); + + return Task.FromResult(true); + } + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs index ebc06239..d568093e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs @@ -1,12 +1,186 @@ -using Microsoft.AspNetCore.Hosting; +using CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CodeBeam.UltimateAuth.Tests.Integration; public class AuthServerFactory : WebApplicationFactory { + private readonly Action? _configureServer; + + public IntegrationTestClock Clock { get; } = new(); + + public AuthServerFactory() + { + } + + private AuthServerFactory(Action configureServer) + { + _configureServer = configureServer; + } + + public static AuthServerFactory Create(Action configureServer) + { + ArgumentNullException.ThrowIfNull(configureServer); + + return new AuthServerFactory(configureServer); + } + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); + + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + + if (_configureServer is not null) + { + services.PostConfigure(options => _configureServer(options)); + } + }); + } + + internal async Task CreateLoginUserAsync(string? identifier = null, string? secret = null, TenantKey? tenant = null, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + + var services = scope.ServiceProvider; + + var lifecycleFactory = + services.GetRequiredService(); + + var identifierFactory = + services.GetRequiredService(); + + var credentialFactory = + services.GetRequiredService(); + + var normalizer = + services.GetRequiredService(); + + var hasher = + services.GetRequiredService(); + + var clock = + services.GetRequiredService(); + + var effectiveTenant = + tenant ?? TenantKeys.Single; + + var userKey = + UserKey.New(); + + identifier ??= + $"test-{Guid.NewGuid():N}"; + + secret ??= + $"Test-{Guid.NewGuid():N}!"; + + var now = + clock.UtcNow; + + var lifecycleStore = + lifecycleFactory.Create(effectiveTenant); + + var identifierStore = + identifierFactory.Create(effectiveTenant); + + var credentialStore = + credentialFactory.Create(effectiveTenant); + + await lifecycleStore.AddAsync( + UserLifecycle.Create( + effectiveTenant, + userKey, + now), + ct); + + var normalized = + normalizer + .Normalize( + UserIdentifierType.Username, + identifier) + .Normalized; + + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + effectiveTenant, + userKey, + UserIdentifierType.Username, + identifier, + normalized, + now, + isPrimary: true, + verifiedAt: now), + ct); + + await credentialStore.AddAsync( + PasswordCredential.Create( + Guid.NewGuid(), + effectiveTenant, + userKey, + hasher.Hash(secret), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now), + ct); + + return new IntegrationTestUser( + userKey, + identifier, + secret); + } + + internal async Task GrantPermissionsAsync(UserKey userKey, IEnumerable permissions, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + + var services = scope.ServiceProvider; + + var roleStoreFactory = + services.GetRequiredService(); + + var userRoleStoreFactory = + services.GetRequiredService(); + + var clock = + services.GetRequiredService(); + + var tenant = TenantKeys.Single; + var now = clock.UtcNow; + + var roleStore = roleStoreFactory.Create(tenant); + var userRoleStore = userRoleStoreFactory.Create(tenant); + + var role = Role.Create( + id: null, + tenant: tenant, + name: $"integration-test-{Guid.NewGuid():N}", + permissions: permissions.Select(Permission.From), + now: now); + + await roleStore.AddAsync(role, ct); + + await userRoleStore.AssignAsync( + userKey, + role.Id, + now, + ct); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs new file mode 100644 index 00000000..ad56dd32 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestClock.cs @@ -0,0 +1,48 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class IntegrationTestClock : IClock +{ + private readonly object _sync = new(); + + private DateTimeOffset _utcNow = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public DateTimeOffset UtcNow + { + get + { + lock (_sync) + { + return _utcNow; + } + } + } + + public void Advance(TimeSpan duration) + { + if (duration < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(duration)); + + lock (_sync) + { + _utcNow = _utcNow.Add(duration); + } + } + + public void Set(DateTimeOffset value) + { + lock (_sync) + { + _utcNow = value.ToUniversalTime(); + } + } + + public void Reset() + { + lock (_sync) + { + _utcNow = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestUser.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestUser.cs new file mode 100644 index 00000000..5193560c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/IntegrationTestUser.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; + +internal sealed record IntegrationTestUser( + UserKey UserKey, + string Identifier, + string Secret); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs index 5d8a86af..dfc3fc41 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -1,4 +1,6 @@ -using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; @@ -8,90 +10,1913 @@ namespace CodeBeam.UltimateAuth.Tests.Integration; public class LoginTests : IClassFixture { - private readonly HttpClient _client; + private const string LoginEndpoint = "/auth/login"; + private const string ValidIdentifier = "admin"; + private const string ValidSecret = "admin"; + + private readonly AuthServerFactory _factory; public LoginTests(AuthServerFactory factory) { - _client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); - - _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); - _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + _factory = factory; } [Fact] - public async Task Login_Should_Return_Cookie() + public async Task Login_WithValidCredentials_ShouldIssueSessionCredential() { - var response = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient($"valid-session-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); response.StatusCode.Should().Be(HttpStatusCode.Found); - response.Headers.Location.Should().NotBeNull(); + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); - cookies.Should().NotBeNull(); + cookies.Should().NotBeNullOrEmpty(); + + var cookie = GetSessionCookie(response); + cookie.Should().NotBeNullOrWhiteSpace(); } [Fact] - public async Task Session_Lifecycle_Should_Work_Correctly() + public async Task Login_WithValidCredentials_ShouldCreateUsableAuthenticatedSession() { - var loginResponse1 = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"usable-session-{Guid.NewGuid():N}"; + using var client = CreateClient(deviceId); + var loginResponse = await LoginAsync(client, user.Identifier, user.Secret); + loginResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(loginResponse); + + using var authenticatedClient = CreateClient(deviceId); + authenticatedClient.DefaultRequestHeaders.Add("Cookie", cookie); + + var response = await authenticatedClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 10 + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync>(); + + result.Should().NotBeNull(); + result!.Items.Should().NotBeEmpty(); + + var currentChain = result.Items.Single(x => x.IsCurrentDevice); + + currentChain.ActiveSessionId.Should().NotBeNull(); + currentChain.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Login_WithInvalidPassword_ShouldNotAuthenticateUser() + { + using var client = CreateClient(); + + var response = await LoginAsync(client, ValidIdentifier, "wrong-password"); + + response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + response.Headers.TryGetValues("Set-Cookie", out _).Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnknownIdentifier_ShouldNotAuthenticateUser() + { + using var client = CreateClient(); + + var response = await LoginAsync(client, "unknown-user", ValidSecret); + + response.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + response.Headers.TryGetValues("Set-Cookie", out _).Should().BeFalse(); + } + + [Theory] + [InlineData("", "admin")] + [InlineData(" ", "admin")] + [InlineData("admin", "")] + [InlineData("admin", " ")] + public async Task Login_WithMissingCredentials_ShouldNotAuthenticateUser(string identifier, string secret) + { + using var client = CreateClient(); + + var response = await LoginAsync( + client, + identifier, + secret); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Unauthorized, + HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticateUser() + { + using var client = CreateClient(); + + using var content = new StringContent( + "identifier=admin&secret=admin"); + + var response = await client.PostAsync( + LoginEndpoint, + content); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Unauthorized, + HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithInvalidPassword_ShouldNotIssueSessionCredential() + { + using var client = CreateClient(); + + var response = await LoginAsync( + client, + ValidIdentifier, + "definitely-wrong-password"); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnknownIdentifier_ShouldBehaveLikeInvalidPassword() + { + using var invalidPasswordClient = CreateClient( + "enumeration-device-1111111111111111"); + + using var unknownUserClient = CreateClient( + "enumeration-device-2222222222222222"); + + var invalidPasswordResponse = await LoginAsync( + invalidPasswordClient, + ValidIdentifier, + "definitely-wrong-password"); + + var unknownUserResponse = await LoginAsync( + unknownUserClient, + "user-that-does-not-exist", + "definitely-wrong-password"); + + invalidPasswordResponse.StatusCode + .Should() + .Be(unknownUserResponse.StatusCode); + + invalidPasswordResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); - loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found); + unknownUserResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_AfterMaximumFailedAttempts_ShouldRejectCorrectPassword() + { + using var client = CreateClient( + "lockout-device-111111111111111111"); + + var firstFailure = await LoginAsync( + client, + ValidIdentifier, + "wrong-password-1"); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + var secondFailure = await LoginAsync( + client, + ValidIdentifier, + "wrong-password-2"); + + secondFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + var correctPasswordDuringLockout = await LoginAsync( + client, + ValidIdentifier, + ValidSecret); + + correctPasswordDuringLockout.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + correctPasswordDuringLockout.StatusCode.Should().BeOneOf( + HttpStatusCode.Unauthorized, + HttpStatusCode.Found); + } + + [Fact] + public async Task Login_AfterLockoutExpires_ShouldAllowCorrectCredentials() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"lockout-expiry-{Guid.NewGuid():N}"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + var duringLockout = await LoginAsync( + client, + user.Identifier, + user.Secret); + + duringLockout.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + _factory.Clock.Advance( + TimeSpan.FromSeconds(11)); + + var afterLockout = await LoginAsync( + client, + user.Identifier, + user.Secret); + + afterLockout.StatusCode.Should() + .Be(HttpStatusCode.Found); + + afterLockout.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TryLogin_WithValidCredentials_ShouldReturnSuccessfulPreview() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"try-login-valid-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); - var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").FirstOrDefault(); - cookie1.Should().NotBeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); - _client.DefaultRequestHeaders.Add("Cookie", cookie1!); + result.Should().NotBeNull(); - var logoutResponse = await _client.PostAsync("/auth/logout", null); - logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); - - var logoutAgain = await _client.PostAsync("/auth/logout", null); - logoutAgain.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + result!.Success.Should().BeTrue(); + result.Reason.Should().BeNull(); + result.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } - _client.DefaultRequestHeaders.Remove("Cookie"); + [Fact] + public async Task TryLogin_WithValidCredentials_ShouldNotAuthenticateUser() + { + using var client = CreateClient( + "try-login-device-2222222222222222"); - var loginResponse2 = await _client.PostAsJsonAsync("/auth/login", new + var response = await client.PostAsJsonAsync("/auth/try-login", new { - identifier = "admin", - secret = "admin" + identifier = ValidIdentifier, + secret = ValidSecret }); - loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found); - var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").FirstOrDefault(); - cookie2.Should().NotBeNull(); - cookie2.Should().NotBe(cookie1); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeTrue(); + + var meResponse = await client.PostAsJsonAsync( + "/auth/me/profile/get", + new GetProfileRequest + { + ProfileKey = null + }); + + meResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task TryLogin_WithInvalidCredentials_ShouldReturnFailedPreviewWithoutSession() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"try-invalid-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = "wrong-password" + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNull(); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); } [Fact] - public async Task Authenticated_User_Should_Access_Me_Endpoint() + public async Task TryLogin_WithMissingCredentials_ShouldReturnFailedPreview() { - var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + using var client = CreateClient( + "try-login-device-4444444444444444"); + + var response = await client.PostAsJsonAsync("/auth/try-login", new { - identifier = "admin", - secret = "admin" + identifier = "", + secret = "" }); - var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); - var response = await _client.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = null }); response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNull(); + } + + [Fact] + public async Task Login_WithValidPreviewReceipt_ShouldAuthenticateUser() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"preview-valid-{Guid.NewGuid():N}"); + + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + var loginResponse = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = user.Secret, + previewReceipt = preview.PreviewReceipt + }); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + loginResponse.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Login_WithPreviewReceiptFromDifferentDevice_ShouldNotTrustReceipt() + { + var user = await _factory.CreateLoginUserAsync(); + + using var previewClient = CreateClient( + $"receipt-owner-{Guid.NewGuid():N}"); + + var previewResponse = await previewClient.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var preview = await previewResponse.Content + .ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + // Attempt to use the valid receipt from another device. + using var attackerClient = CreateClient( + $"receipt-attacker-{Guid.NewGuid():N}"); + + var response = await attackerClient.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = "wrong-password", + previewReceipt = preview.PreviewReceipt + }); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithPreviewReceiptAndDifferentSecret_ShouldNotAuthenticate() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"preview-different-secret-{Guid.NewGuid():N}"); + + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + var loginResponse = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = "different-secret", + previewReceipt = preview.PreviewReceipt + }); + + loginResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithPreviewReceiptAndDifferentIdentifier_ShouldFallBackToNormalAuthentication() + { + var receiptOwner = await _factory.CreateLoginUserAsync(); + var otherUser = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"preview-different-identifier-{Guid.NewGuid():N}"); + + // Create receipt for user A. + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = receiptOwner.Identifier, + secret = receiptOwner.Secret + }); + + previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + // Present user A's receipt while authenticating as user B. + // + // The receipt must NOT be trusted for user B, but it also must not + // prevent user B from authenticating with valid credentials. + var loginResponse = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = otherUser.Identifier, + secret = otherUser.Secret, + previewReceipt = preview.PreviewReceipt + }); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + loginResponse.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + + GetSessionCookie(loginResponse) + .Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Login_WithPreviewReceiptFromDifferentIdentifier_ShouldNotBypassCredentialValidation() + { + var receiptOwner = await _factory.CreateLoginUserAsync(); + var otherUser = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"preview-cross-user-{Guid.NewGuid():N}"); + + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = receiptOwner.Identifier, + secret = receiptOwner.Secret + }); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + // Receipt belongs to user A. + // Attempt user B authentication with INVALID credentials. + var loginResponse = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = otherUser.Identifier, + secret = "definitely-wrong-password", + previewReceipt = preview.PreviewReceipt + }); + + loginResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnknownPreviewReceipt_ShouldFallBackToNormalLoginValidation() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"unknown-receipt-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = user.Secret, + previewReceipt = $"unknown-{Guid.NewGuid():N}" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task PreviewReceipt_AfterSuccessfulCommit_ShouldBeConsumed() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"preview-consume-{Guid.NewGuid():N}"); + + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + var firstCommit = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = user.Secret, + previewReceipt = preview.PreviewReceipt + }); + + firstCommit.StatusCode.Should().Be(HttpStatusCode.Found); + + firstCommit.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + + // Receipt has now been consumed. + // + // Reusing it must not grant any special trust. The request should + // simply fall back to normal credential validation. + var secondCommit = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = "wrong-after-consumption", + previewReceipt = preview.PreviewReceipt + }); + + secondCommit.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithRepeatedInvalidCredentials_ShouldLockAccount() + { + using var client = CreateClient( + "lockout-device-111111111111111111"); + + var first = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-1" + }); + + first.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + var second = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-2" + }); + + second.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // Correct credentials must not bypass an active lockout. + var correctLogin = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = ValidSecret + }); + + correctLogin.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task TryLogin_WithRepeatedInvalidCredentials_ShouldParticipateInLockout() + { + using var client = CreateClient( + "try-lockout-device-222222222222222"); + + var first = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-1" + }); + + var firstResult = + await first.Content.ReadFromJsonAsync(); + + firstResult.Should().NotBeNull(); + firstResult!.Success.Should().BeFalse(); + + var second = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-2" + }); + + var secondResult = + await second.Content.ReadFromJsonAsync(); + + secondResult.Should().NotBeNull(); + secondResult!.Success.Should().BeFalse(); + + // Account should now be locked. + var correctAttempt = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = ValidIdentifier, + secret = ValidSecret + }); + + var correctResult = + await correctAttempt.Content.ReadFromJsonAsync(); + + correctResult.Should().NotBeNull(); + correctResult!.Success.Should().BeFalse(); + correctResult.Reason.Should().Be(AuthFailureReason.LockedOut); + } + + [Fact] + public async Task TryLogin_WithValidCredentials_ShouldNotConsumeFailureAttempt() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"try-no-failure-{Guid.NewGuid():N}"); + + var preview = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + var previewResult = + await preview.Content.ReadFromJsonAsync(); + + previewResult.Should().NotBeNull(); + previewResult!.Success.Should().BeTrue(); + + var failure = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = "wrong-password" + }); + + var failureResult = + await failure.Content.ReadFromJsonAsync(); + + failureResult.Should().NotBeNull(); + failureResult!.Success.Should().BeFalse(); + failureResult.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + } + + [Fact] + public async Task PreviewReceipt_WithDifferentSecret_ShouldNotSuppressFailureAccounting() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"receipt-accounting-{Guid.NewGuid():N}"); + + var previewResponse = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + previewResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + // Receipt was created for the correct secret. + // Using it with another secret must NOT suppress failure accounting. + var firstFailure = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = "wrong-password-1", + previewReceipt = preview.PreviewReceipt + }); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // MaxFailedAttempts = 2. + // This must therefore be failure #2. + var secondFailure = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = "wrong-password-2" + }); + + secondFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // Account must now be locked. Correct credentials cannot authenticate. + var correctLogin = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + correctLogin.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); } [Fact] - public async Task Anonymous_Should_Not_Access_Me() + public async Task PreviewReceipt_FromDifferentDevice_ShouldNotSuppressFailureAccounting() { - var response = await _client.PostAsync("/auth/me/profile/get", null); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var user = await _factory.CreateLoginUserAsync(); + + using var receiptClient = CreateClient( + $"receipt-device-a-{Guid.NewGuid():N}"); + + var previewResponse = await receiptClient.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + var preview = + await previewResponse.Content.ReadFromJsonAsync(); + + preview.Should().NotBeNull(); + preview!.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + using var otherDevice = CreateClient( + $"receipt-device-b-{Guid.NewGuid():N}"); + + // Different device must not be able to use the receipt + // to suppress this failure. + var firstFailure = await otherDevice.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = "wrong-password-1", + previewReceipt = preview.PreviewReceipt + }); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // MaxFailedAttempts = 2. If the previous failure was correctly + // accounted for, this second failure must lock the account. + var secondFailure = await otherDevice.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = "wrong-password-2" + }); + + secondFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + var correctLogin = await otherDevice.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + correctLogin.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task SuccessfulLogin_ShouldResetPreviousFailureAccounting() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"success-reset-{Guid.NewGuid():N}"); + + // Failure #1 + var firstFailure = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = "wrong-password-1" + }); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // Successful authentication must reset previous failure accounting. + var success = await client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier = user.Identifier, + secret = user.Secret + }); + + success.StatusCode.Should().Be(HttpStatusCode.Found); + + success.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeTrue(); + + // If the previous failure was reset, this is failure #1 again, + // not failure #2 / lockout. + var failureAfterSuccess = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = "wrong-password-2" + }); + + var result = + await failureAfterSuccess.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + } + + [Fact] + public async Task Lockout_ShouldApplyAcrossDevices() + { + using var attackerDevice = CreateClient( + "lockout-source-device-99999999999999"); + + await attackerDevice.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-1" + }); + + await attackerDevice.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password-2" + }); + + using var differentDevice = CreateClient( + "lockout-other-device-000000000000000"); + + var response = await differentDevice.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = ValidSecret + }); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + #region HTTP Contract & Input Boundary + + [Fact] + public async Task Login_WithFormPayload_ShouldAuthenticateUser() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"form-login-{Guid.NewGuid():N}"); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["Identifier"] = user.Identifier, + ["Secret"] = user.Secret + }); + + var response = await client.PostAsync( + LoginEndpoint, + content); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task TryLogin_WithFormPayload_ShouldReturnSuccessfulPreview() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"form-try-login-{Guid.NewGuid():N}"); + + using var content = new FormUrlEncodedContent( + new Dictionary + { + ["Identifier"] = user.Identifier, + ["Secret"] = user.Secret + }); + + var response = await client.PostAsync( + "/auth/try-login", + content); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeTrue(); + result.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Theory] + [InlineData("", "admin")] + [InlineData(" ", "admin")] + [InlineData("admin", "")] + [InlineData("admin", " ")] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task Login_WithMissingOrWhitespaceCredentials_ShouldNotAuthenticate( + string identifier, + string secret) + { + using var client = CreateClient( + $"invalid-input-device-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier, + secret + }); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Theory] + [InlineData("", "admin")] + [InlineData(" ", "admin")] + [InlineData("admin", "")] + [InlineData("admin", " ")] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task TryLogin_WithMissingOrWhitespaceCredentials_ShouldReturnInvalidCredentials( + string identifier, + string secret) + { + using var client = CreateClient( + $"invalid-preview-device-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier, + secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNull(); + } + + [Fact] + public async Task TryLogin_WithUnsupportedContentType_ShouldReturnBadRequest() + { + using var client = CreateClient( + "content-type-device-333333333333333"); + + using var content = new StringContent( + "identifier=admin&secret=admin", + System.Text.Encoding.UTF8, + "text/plain"); + + var response = await client.PostAsync("/auth/try-login", content); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnsupportedContentType_ShouldNotAuthenticate() + { + using var client = CreateClient( + "login-content-type-device-444444444"); + + using var content = new StringContent( + "identifier=admin&secret=admin", + System.Text.Encoding.UTF8, + "text/plain"); + + var response = await client.PostAsync("/auth/login", content); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + [Fact] + public async Task TryLogin_WithEmptyJsonObject_ShouldReturnInvalidCredentials() + { + using var client = CreateClient( + "empty-json-device-555555555555555"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new { }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNull(); + } + + [Fact] + public async Task Login_WithEmptyJsonObject_ShouldNotAuthenticate() + { + using var client = CreateClient( + "empty-login-json-device-66666666666"); + + var response = await client.PostAsJsonAsync( + "/auth/login", + new { }); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + [Fact] + public async Task Login_WithJsonPropertyNamesUsingDifferentCasing_ShouldAuthenticate() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"json-casing-{Guid.NewGuid():N}"); + + using var content = JsonContent.Create(new Dictionary + { + ["IDENTIFIER"] = user.Identifier, + ["SECRET"] = user.Secret + }); + + var response = await client.PostAsync( + LoginEndpoint, + content); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + + GetSessionCookie(response) + .Should().NotBeNullOrWhiteSpace(); + } + + #endregion + + #region Identifier Enumeration & Failure Disclosure + + [Fact] + public async Task Login_WithUnknownIdentifier_ShouldNotRevealWhetherUserExists() + { + using var unknownUserClient = CreateClient( + $"enumeration-unknown-{Guid.NewGuid():N}"); + + using var existingUserClient = CreateClient( + $"enumeration-existing-{Guid.NewGuid():N}"); + + var unknownResponse = await unknownUserClient.PostAsJsonAsync( + "/auth/login", + new + { + identifier = $"unknown-{Guid.NewGuid():N}", + secret = "wrong-password" + }); + + var existingResponse = await existingUserClient.PostAsJsonAsync( + "/auth/login", + new + { + identifier = ValidIdentifier, + secret = "wrong-password" + }); + + unknownResponse.StatusCode.Should().Be(existingResponse.StatusCode); + + unknownResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + existingResponse.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task TryLogin_WithUnknownIdentifier_ShouldReturnSameFailureReasonAsWrongPassword() + { + var existingUser = await _factory.CreateLoginUserAsync(); + + using var unknownClient = CreateClient( + $"unknown-{Guid.NewGuid():N}"); + + using var existingClient = CreateClient( + $"existing-{Guid.NewGuid():N}"); + + var unknownResponse = await unknownClient.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = $"unknown-{Guid.NewGuid():N}", + secret = "wrong-password" + }); + + var existingResponse = await existingClient.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = existingUser.Identifier, + secret = "wrong-password" + }); + + unknownResponse.StatusCode.Should().Be(HttpStatusCode.OK); + existingResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var unknownResult = + await unknownResponse.Content.ReadFromJsonAsync(); + + var existingResult = + await existingResponse.Content.ReadFromJsonAsync(); + + unknownResult.Should().NotBeNull(); + existingResult.Should().NotBeNull(); + + unknownResult!.Success.Should().BeFalse(); + existingResult!.Success.Should().BeFalse(); + + unknownResult.Reason + .Should().Be(AuthFailureReason.InvalidCredentials); + + existingResult.Reason + .Should().Be(AuthFailureReason.InvalidCredentials); + + unknownResult.PreviewReceipt.Should().BeNullOrWhiteSpace(); + existingResult.PreviewReceipt.Should().BeNullOrWhiteSpace(); + } + + [Fact] + public async Task TryLogin_WithUnknownIdentifier_ShouldNotIssuePreviewReceipt() + { + using var client = CreateClient( + $"unknown-receipt-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = $"unknown-{Guid.NewGuid():N}", + secret = "some-password" + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNullOrWhiteSpace(); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task TryLogin_WithWrongPassword_ShouldNotIssuePreviewReceipt() + { + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"wrong-password-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/try-login", + new + { + identifier = user.Identifier, + secret = "definitely-wrong-password" + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + result!.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + result.PreviewReceipt.Should().BeNullOrWhiteSpace(); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_WithUnknownIdentifier_ShouldNotReturnAuthenticationCredential() + { + using var client = CreateClient( + $"unknown-credential-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = $"unknown-{Guid.NewGuid():N}", + secret = ValidSecret + }); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + ((int)response.StatusCode) + .Should().BeLessThan(500); + } + + #endregion + + [Fact] + public async Task Login_FailuresOutsideFailureWindow_ShouldNotAccumulateTowardLockout() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"failure-window-{Guid.NewGuid():N}"); + + // Failure #1 + var firstFailure = await LoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + firstFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // Default FailureWindow = 15 minutes. + // Move beyond the window. + _factory.Clock.Advance( + TimeSpan.FromMinutes(16)); + + // This should begin a new failure window, + // rather than becoming failure #2 of the old window. + var secondFailure = await LoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + secondFailure.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + // If the old failure was incorrectly retained, + // MaxFailedAttempts = 2 would have locked the account. + var correctLogin = await LoginAsync( + client, + user.Identifier, + user.Secret); + + correctLogin.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Login_BeforeLockoutExpires_ShouldRemainLocked() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"active-lockout-{Guid.NewGuid():N}"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + _factory.Clock.Advance( + TimeSpan.FromSeconds(9)); + + var response = await LoginAsync( + client, + user.Identifier, + user.Secret); + + response.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + } + + [Fact] + public async Task Login_AtLockoutExpirationBoundary_ShouldAllowCorrectCredentials() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"lockout-boundary-{Guid.NewGuid():N}"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + await LoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + _factory.Clock.Advance( + TimeSpan.FromSeconds(10)); + + var response = await LoginAsync( + client, + user.Identifier, + user.Secret); + + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Login_FailuresWithinFailureWindow_ShouldAccumulateTowardLockout() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"failure-window-inside-{Guid.NewGuid():N}"); + + var first = await TryLoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + first.Success.Should().BeFalse(); + first.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + first.RemainingAttempts.Should().Be(1); + first.LockoutUntilUtc.Should().BeNull(); + + _factory.Clock.Advance(TimeSpan.FromMinutes(14)); + + var second = await TryLoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + second.Success.Should().BeFalse(); + second.Reason.Should().Be(AuthFailureReason.LockedOut); + second.RemainingAttempts.Should().Be(0); + second.LockoutUntilUtc.Should().NotBeNull(); + } + + [Fact] + public async Task Login_FailureDuringLockout_ShouldNotExtendLockout_WhenExtendLockOnFailureIsDisabled() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"lockout-no-extension-{Guid.NewGuid():N}"); + + var first = await TryLoginAsync( + client, + user.Identifier, + "wrong-password-1"); + + first.Success.Should().BeFalse(); + first.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + + var second = await TryLoginAsync( + client, + user.Identifier, + "wrong-password-2"); + + second.Success.Should().BeFalse(); + second.Reason.Should().Be(AuthFailureReason.LockedOut); + second.LockoutUntilUtc.Should().NotBeNull(); + + var originalLockoutUntil = second.LockoutUntilUtc!.Value; + + _factory.Clock.Advance(TimeSpan.FromSeconds(5)); + + var duringLockout = await TryLoginAsync( + client, + user.Identifier, + "still-wrong"); + + duringLockout.Success.Should().BeFalse(); + duringLockout.Reason.Should().Be(AuthFailureReason.LockedOut); + + duringLockout.LockoutUntilUtc + .Should().Be(originalLockoutUntil); + + _factory.Clock.Set( + originalLockoutUntil.AddMilliseconds(1)); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + login.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should().BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task ConcurrentLoginFailures_ShouldNotLoseFailureAttempts() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client1 = CreateClient( + $"concurrent-failure-1-{Guid.NewGuid():N}"); + + using var client2 = CreateClient( + $"concurrent-failure-2-{Guid.NewGuid():N}"); + + var task1 = TryLoginAsync( + client1, + user.Identifier, + "wrong-password-1"); + + var task2 = TryLoginAsync( + client2, + user.Identifier, + "wrong-password-2"); + + var results = await Task.WhenAll(task1, task2); + + results.Should().OnlyContain(x => !x.Success); + + using var verificationClient = CreateClient( + $"concurrent-failure-verification-{Guid.NewGuid():N}"); + + var verification = await TryLoginAsync( + verificationClient, + user.Identifier, + user.Secret); + + verification.Success.Should().BeFalse(); + verification.Reason.Should().Be(AuthFailureReason.LockedOut); + verification.RemainingAttempts.Should().Be(0); + verification.LockoutUntilUtc.Should().NotBeNull(); + } + + [Fact] + public async Task ConcurrentSuccessfulLogins_FromDifferentDevices_ShouldCreateUsableSessions() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"concurrent-success-1-{Guid.NewGuid():N}"; + var device2 = $"concurrent-success-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var responses = await Task.WhenAll( + LoginAsync(client1, user.Identifier, user.Secret), + LoginAsync(client2, user.Identifier, user.Secret)); + + responses.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Found); + + var cookie1 = GetSessionCookie(responses[0]); + var cookie2 = GetSessionCookie(responses[1]); + + cookie1.Should().NotBe(cookie2); + + using var authenticatedClient1 = CreateClient(device1); + using var authenticatedClient2 = CreateClient(device2); + + authenticatedClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + authenticatedClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var sessionResponses = await Task.WhenAll( + authenticatedClient1.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 10 + }), + + authenticatedClient2.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 10 + })); + + sessionResponses.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.OK); + + var result1 = + await sessionResponses[0] + .Content + .ReadFromJsonAsync>(); + + var result2 = + await sessionResponses[1] + .Content + .ReadFromJsonAsync>(); + + result1.Should().NotBeNull(); + result2.Should().NotBeNull(); + + result1!.Items.Should().Contain(x => + x.IsCurrentDevice && + x.ActiveSessionId != null && + !x.IsRevoked); + + result2!.Items.Should().Contain(x => + x.IsCurrentDevice && + x.ActiveSessionId != null && + !x.IsRevoked); + } + + [Fact] + public async Task ConcurrentLogin_WithSamePreviewReceipt_ShouldNotAllowReceiptToBeConsumedTwice() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device = $"preview-concurrent-{Guid.NewGuid():N}"; + + using var previewClient = CreateClient(device); + + var preview = await TryLoginAsync( + previewClient, + user.Identifier, + user.Secret); + + preview.Success.Should().BeTrue(); + preview.PreviewReceipt.Should().NotBeNullOrWhiteSpace(); + + var receipt = preview.PreviewReceipt!; + + using var client1 = CreateClient(device); + using var client2 = CreateClient(device); + + var responses = await Task.WhenAll( + LoginWithPreviewReceiptAsync( + client1, + user.Identifier, + user.Secret, + receipt), + + LoginWithPreviewReceiptAsync( + client2, + user.Identifier, + user.Secret, + receipt)); + + responses.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Found); + + using var replayClient = CreateClient(device); + + var replay = await LoginWithPreviewReceiptAsync( + replayClient, + user.Identifier, + "wrong-password", + receipt); + + replay.Headers + .TryGetValues("Set-Cookie", out _) + .Should().BeFalse(); + + var failureState = await TryLoginAsync( + replayClient, + user.Identifier, + "another-wrong-password"); + + failureState.Success.Should().BeFalse(); + failureState.Reason.Should().Be(AuthFailureReason.LockedOut); + } + + + private HttpClient CreateClient( + string deviceId = "test-device-1234567890123456") + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static Task LoginAsync(HttpClient client, string identifier, string secret) + { + return client.PostAsJsonAsync( + LoginEndpoint, + new + { + identifier, + secret + }); + } + + private static async Task TryLoginAsync(HttpClient client, string identifier, string secret) + { + var response = await client.PostAsJsonAsync( + "auth/try-login", + new LoginRequest + { + Identifier = identifier, + Secret = secret + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + + return result!; + } + + private static Task LoginWithPreviewReceiptAsync(HttpClient client, string identifier, string secret, string previewReceipt) + { + return client.PostAsJsonAsync("/auth/login", new + { + identifier, + secret, + previewReceipt + }); + } + + private static string GetSessionCookie(HttpResponseMessage response) + { + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + cookies.Should().NotBeNullOrEmpty(); + + return cookies!.First(); } -} \ No newline at end of file +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs new file mode 100644 index 00000000..3185e136 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutAdminTests.cs @@ -0,0 +1,1463 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class LogoutAdminTests : IClassFixture +{ + private readonly AuthServerFactory _factory; + + public LogoutAdminTests(AuthServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task LogoutDeviceAdmin_WithoutAuthentication_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var target = await _factory.CreateLoginUserAsync(); + + using var targetClient = CreateClient( + $"admin-logout-device-target-{Guid.NewGuid():N}"); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var chainsResponse = await GetChainsAsync(targetClient); + + chainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var targetChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + using var anonymousClient = CreateClient( + $"admin-logout-device-anonymous-{Guid.NewGuid():N}"); + + var response = await anonymousClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceAdmin_WithoutRequiredPermission_ShouldReturnForbidden() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"admin-logout-device-unprivileged-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"admin-logout-device-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var targetChainsResponse = + await GetChainsAsync(targetClient); + + targetChainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var targetChains = await targetChainsResponse.Content + .ReadFromJsonAsync>(); + + targetChains.Should().NotBeNull(); + + var targetChain = targetChains!.Items + .Single(x => x.IsCurrentDevice); + + var response = await actorClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var actorVerification = + await GetChainsAsync(actorClient); + + actorVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_WithoutAuthentication_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var target = await _factory.CreateLoginUserAsync(); + + using var targetClient = CreateClient( + $"admin-logout-all-target-{Guid.NewGuid():N}"); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + using var anonymousClient = CreateClient( + $"admin-logout-all-anonymous-{Guid.NewGuid():N}"); + + var response = await anonymousClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Target authority must survive rejected request. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_WithoutRequiredPermission_ShouldReturnForbidden() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"admin-logout-all-unprivileged-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"admin-logout-all-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var response = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + + // + // Denied admin command must be mutation-free. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(actorClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceAdmin_WithPermission_ShouldInvalidateTargetDeviceSession() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutDeviceAdmin + ]); + + var adminDevice = + $"logout-admin-{Guid.NewGuid():N}"; + + var targetDevice = + $"logout-target-{Guid.NewGuid():N}"; + + using var adminClient = + CreateClient(adminDevice); + + using var targetClient = + CreateClient(targetDevice); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + // + // Resolve target's chain. + // + var targetChainsResponse = + await GetChainsAsync(targetClient); + + targetChainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var targetChains = await targetChainsResponse.Content + .ReadFromJsonAsync>(); + + targetChains.Should().NotBeNull(); + + var targetChain = targetChains!.Items + .Single(x => x.IsCurrentDevice); + + // + // Admin logs out target device. + // + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Target's existing session must now be unusable. + // + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Administrative action must not destroy actor's session. + // + var adminVerification = + await GetChainsAsync(adminClient); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceAdmin_ShouldNotInvalidateTargetUsersOtherDevice() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutDeviceAdmin + ]); + + using var adminClient = CreateClient( + $"logout-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + // + // Resolve device 1 chain. + // + var chainsResponse = + await GetChainsAsync(targetClient1); + + chainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var targetChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + // + // Admin terminates only device 1. + // + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Device 1 is dead. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Device 2 must survive. + // + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Admin must survive as well. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceAdmin_ShouldNotAllowChainFromDifferentTargetUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var targetA = await _factory.CreateLoginUserAsync(); + var targetB = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutDeviceAdmin + ]); + + using var adminClient = CreateClient( + $"logout-admin-{Guid.NewGuid():N}"); + + using var targetAClient = CreateClient( + $"logout-target-a-{Guid.NewGuid():N}"); + + using var targetBClient = CreateClient( + $"logout-target-b-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetACookie = GetSessionCookie( + await LoginAsync( + targetAClient, + targetA.Identifier, + targetA.Secret)); + + var targetBCookie = GetSessionCookie( + await LoginAsync( + targetBClient, + targetB.Identifier, + targetB.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetAClient.DefaultRequestHeaders.Add( + "Cookie", + targetACookie); + + targetBClient.DefaultRequestHeaders.Add( + "Cookie", + targetBCookie); + + // + // Obtain B's chain. + // + var targetBChainsResponse = + await GetChainsAsync(targetBClient); + + targetBChainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var targetBChains = await targetBChainsResponse.Content + .ReadFromJsonAsync>(); + + targetBChains.Should().NotBeNull(); + + var targetBChain = targetBChains!.Items + .Single(x => x.IsCurrentDevice); + + // + // URL says target A, but supplied chain belongs to B. + // + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{targetA.UserKey.Value}/logout-device", + new LogoutDeviceRequest + { + ChainId = targetBChain.ChainId + }); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound); + + // + // Most important assertion: + // B's session must NOT have been mutated. + // + (await GetChainsAsync(targetBClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // A must also remain untouched. + // + (await GetChainsAsync(targetAClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Admin remains authenticated. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutOthersAdmin_ShouldLogoutAllTargetDevicesExceptSpecifiedChain() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutOthersAdmin + ]); + + using var adminClient = CreateClient($"logout-others-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient($"logout-others-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient($"logout-others-target-2-{Guid.NewGuid():N}"); + + using var targetClient3 = CreateClient($"logout-others-target-3-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie(await LoginAsync(adminClient, admin.Identifier, admin.Secret)); + + var cookie1 = GetSessionCookie(await LoginAsync(targetClient1, target.Identifier, target.Secret)); + + var cookie2 = GetSessionCookie(await LoginAsync(targetClient2, target.Identifier, target.Secret)); + + var cookie3 = GetSessionCookie(await LoginAsync(targetClient3, target.Identifier, target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + targetClient3.DefaultRequestHeaders.Add("Cookie", cookie3); + + var chainsResponse = await GetChainsAsync(targetClient1); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content.ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var currentChain = chains!.Items.Single(x => x.IsCurrentDevice); + + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-others", + new LogoutOtherDevicesRequest + { + CurrentChainId = currentChain.ChainId + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient1)).StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient3)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(adminClient)).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutOthersAdmin_WithoutRequiredPermission_ShouldReturnForbiddenAndNotMutateSessions() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"logout-others-actor-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-others-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-others-target-2-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var cookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var chainsResponse = + await GetChainsAsync(targetClient1); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var currentChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + var response = await actorClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-others", + new LogoutOtherDevicesRequest + { + CurrentChainId = currentChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + + // + // Authorization denial must be mutation-free. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(actorClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutOthersAdmin_ShouldRejectCurrentChainFromDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + var otherUser = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutOthersAdmin + ]); + + using var adminClient = CreateClient( + $"logout-others-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-others-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-others-target-2-{Guid.NewGuid():N}"); + + using var otherClient = CreateClient( + $"logout-others-other-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + var otherCookie = GetSessionCookie( + await LoginAsync( + otherClient, + otherUser.Identifier, + otherUser.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", targetCookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", targetCookie2); + otherClient.DefaultRequestHeaders.Add("Cookie", otherCookie); + + // + // Get a chain belonging to a completely different user. + // + var otherChainsResponse = + await GetChainsAsync(otherClient); + + var otherChains = await otherChainsResponse.Content + .ReadFromJsonAsync>(); + + otherChains.Should().NotBeNull(); + + var foreignChain = otherChains!.Items + .Single(x => x.IsCurrentDevice); + + // + // URL target = target + // CurrentChainId = otherUser's chain + // + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-others", + new LogoutOtherDevicesRequest + { + CurrentChainId = foreignChain.ChainId + }); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound); + + // + // Invalid target/chain combination must cause NO mutation. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(otherClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutOthersAdmin_WithSingleTargetDevice_ShouldPreserveThatDevice() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutOthersAdmin + ]); + + using var adminClient = CreateClient( + $"logout-others-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"logout-others-target-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var chainsResponse = + await GetChainsAsync(targetClient); + + chainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var currentChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-others", + new LogoutOtherDevicesRequest + { + CurrentChainId = currentChain.ChainId + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // There were no "other" devices. + // The preserved device must remain usable. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Administrative actor must also remain unaffected. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutOthersAdmin_WhenRepeated_ShouldRemainIdempotent() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutOthersAdmin + ]); + + using var adminClient = CreateClient( + $"logout-others-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-others-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-others-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + var chainsResponse = + await GetChainsAsync(targetClient1); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var preservedChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + var request = new LogoutOtherDevicesRequest + { + CurrentChainId = preservedChain.ChainId + }; + + var url = + $"/auth/admin/users/{target.UserKey.Value}/logout-others"; + + var first = await adminClient.PostAsJsonAsync( + url, + request); + + first.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var second = await adminClient.PostAsJsonAsync( + url, + request); + + second.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Preserved chain survives both operations. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Revoked/detached device must not become usable again. + // + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Admin remains authenticated. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_ShouldLogoutAllTargetDevices() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutAllAdmin + ]); + + using var adminClient = CreateClient( + $"logout-all-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-all-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-all-target-2-{Guid.NewGuid():N}"); + + using var targetClient3 = CreateClient( + $"logout-all-target-3-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + var targetCookie3 = GetSessionCookie( + await LoginAsync( + targetClient3, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + targetClient3.DefaultRequestHeaders.Add( + "Cookie", + targetCookie3); + + // + // Verify all target sessions are initially usable. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient3)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Admin logs out every device belonging to target. + // + var response = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Every target session must now be unusable. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient3)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutAllAdmin_ShouldNotInvalidateAdminSession() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutAllAdmin + ]); + + using var adminClient = CreateClient( + $"logout-all-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"logout-all-target-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var response = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Target must be logged out. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Administrative actor is a different user and + // must remain authenticated. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_WithoutRequiredPermission_ShouldReturnForbiddenAndNotMutateTargetSessions() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"logout-all-actor-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-all-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-all-target-2-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + // + // Establish precondition. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Actor is authenticated but does NOT have + // flows.logoutall.admin. + // + var response = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Forbidden); + + // + // Authorization failure must be mutation-free. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Actor must also remain authenticated. + // + (await GetChainsAsync(actorClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_WhenRepeated_ShouldRemainIdempotent() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutAllAdmin + ]); + + using var adminClient = CreateClient( + $"logout-all-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"logout-all-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"logout-all-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + var url = + $"/auth/admin/users/{target.UserKey.Value}/logout-all"; + + var first = await adminClient.PostAsync( + url, + null); + + first.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var second = await adminClient.PostAsync( + url, + null); + + second.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Sessions must remain logged out. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Repeating the operation must not affect + // the administrative actor. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllAdmin_ShouldNotAffectUnrelatedUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + var unrelated = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Flows.LogoutAllAdmin + ]); + + using var adminClient = CreateClient( + $"logout-all-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"logout-all-target-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"logout-all-unrelated-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + var unrelatedCookie = GetSessionCookie( + await LoginAsync( + unrelatedClient, + unrelated.Identifier, + unrelated.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + unrelatedClient.DefaultRequestHeaders.Add( + "Cookie", + unrelatedCookie); + + var response = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/logout-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Target is logged out. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Completely unrelated user remains authenticated. + // + (await GetChainsAsync(unrelatedClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Administrative actor remains authenticated. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + + // --------------------------------------------------------- + // Helpers + // --------------------------------------------------------- + + private HttpClient CreateClient(string deviceId) + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static async Task LoginAsync(HttpClient client, string identifier, string secret) + { + return await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier, + secret + }); + } + + private static async Task GetChainsAsync(HttpClient client) + { + return await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + } + + private static string GetSessionCookie(HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.Found); + response.Headers.TryGetValues("Set-Cookie", out var values).Should().BeTrue(); + + var cookie = values!.FirstOrDefault(x => x.StartsWith("uas=", StringComparison.OrdinalIgnoreCase)); + cookie.Should().NotBeNullOrWhiteSpace(); + + return cookie!; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs index 436b300c..4a90896f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Users.Contracts; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; @@ -9,247 +10,3580 @@ namespace CodeBeam.UltimateAuth.Tests.Integration; public class LogoutTests : IClassFixture { - private readonly HttpClient _client; - AuthServerFactory _factory; + private readonly AuthServerFactory _factory; public LogoutTests(AuthServerFactory factory) { _factory = factory; + } + + [Fact] + public async Task Logout_WithAuthenticatedSession_ShouldSucceed() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"logout-basic-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var loginResponse = await LoginAsync( + client, + user.Identifier, + user.Secret); + + loginResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(loginResponse); + + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var logoutResponse = await client.PostAsync( + "/auth/logout", + null); + + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + } + + [Fact] + public async Task Logout_ShouldDeleteSessionCookie() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"logout-cookie-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var loginResponse = await LoginAsync( + client, + user.Identifier, + user.Secret); + + var cookie = GetSessionCookie(loginResponse); + + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var logoutResponse = await client.PostAsync( + "/auth/logout", + null); + + logoutResponse.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should() + .BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + + var deletionCookie = cookies! + .FirstOrDefault(x => + x.Contains( + "uas", + StringComparison.OrdinalIgnoreCase)); + + deletionCookie.Should().NotBeNullOrWhiteSpace(); + + deletionCookie.Should().MatchRegex( + "(?i)(expires=|max-age=0)"); + } + + [Fact] + public async Task Logout_ShouldInvalidateSessionOnServer() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"logout-invalidation-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var loginResponse = await LoginAsync( + client, + user.Identifier, + user.Secret); + + var cookie = GetSessionCookie(loginResponse); + + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var beforeLogout = await GetChainsAsync(client); + beforeLogout.StatusCode.Should().Be(HttpStatusCode.OK); + + var logoutResponse = await client.PostAsync("/auth/logout", null); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + // + // IMPORTANT: + // Deliberately keep sending the old cookie. + // + // HandleCookies=false means the logout Set-Cookie response cannot + // magically remove our manually supplied Cookie header. + // + // Therefore this proves server-side invalidation. + // + + var afterLogout = await GetChainsAsync(client); + + afterLogout.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_OldSessionCookie_ShouldNotAuthenticateFromNewClient() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"logout-replay-{Guid.NewGuid():N}"; + + using var originalClient = CreateClient(deviceId); + + var loginResponse = await LoginAsync( + originalClient, + user.Identifier, + user.Secret); + + var cookie = GetSessionCookie(loginResponse); + + originalClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var logoutResponse = await originalClient.PostAsync( + "/auth/logout", + null); + + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + // + // Simulate an attacker / stale browser / copied credential. + // + // A completely new HttpClient receives the original cookie. + // + + using var replayClient = CreateClient(deviceId); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var replayResponse = await GetChainsAsync(replayClient); + + replayResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_ShouldNotInvalidateOtherDeviceSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-device-1-{Guid.NewGuid():N}"; + var device2 = $"logout-device-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var login1 = await LoginAsync( + client1, + user.Identifier, + user.Secret); + + var login2 = await LoginAsync( + client2, + user.Identifier, + user.Secret); + + login1.StatusCode.Should().Be(HttpStatusCode.Found); + login2.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie1 = GetSessionCookie(login1); + var cookie2 = GetSessionCookie(login2); + + client1.DefaultRequestHeaders.Add( + "Cookie", + cookie1); + + client2.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + var before1 = await GetChainsAsync(client1); + var before2 = await GetChainsAsync(client2); + + before1.StatusCode.Should().Be(HttpStatusCode.OK); + before2.StatusCode.Should().Be(HttpStatusCode.OK); + + var logout = await client1.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + var device1AfterLogout = await GetChainsAsync(client1); + var device2AfterLogout = await GetChainsAsync(client2); + + device1AfterLogout.StatusCode + .Should() + .Be(HttpStatusCode.Unauthorized); + + device2AfterLogout.StatusCode + .Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Logout_ShouldNotInvalidateAnotherUsersSession() + { + _factory.Clock.Reset(); + + var user1 = await _factory.CreateLoginUserAsync(); + var user2 = await _factory.CreateLoginUserAsync(); + + using var client1 = CreateClient( + $"logout-user-1-{Guid.NewGuid():N}"); + + using var client2 = CreateClient( + $"logout-user-2-{Guid.NewGuid():N}"); + + var login1 = await LoginAsync( + client1, + user1.Identifier, + user1.Secret); + + var login2 = await LoginAsync( + client2, + user2.Identifier, + user2.Secret); + + var cookie1 = GetSessionCookie(login1); + var cookie2 = GetSessionCookie(login2); + + client1.DefaultRequestHeaders.Add( + "Cookie", + cookie1); + + client2.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + var logout = await client1.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + var user1Response = await GetChainsAsync(client1); + var user2Response = await GetChainsAsync(client2); + + user1Response.StatusCode + .Should() + .Be(HttpStatusCode.Unauthorized); + + user2Response.StatusCode + .Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Logout_ShouldDetachActiveSessionFromCurrentChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-detach-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync(client, user.Identifier, user.Secret); + + var cookie = GetSessionCookie(login); + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var beforeResponse = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + beforeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content.ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var currentBefore = before!.Items.Single(x => x.IsCurrentDevice); + + currentBefore.ActiveSessionId.Should().NotBeNull(); + currentBefore.IsRevoked.Should().BeFalse(); + + var chainId = currentBefore.ChainId; + var sessionId = currentBefore.ActiveSessionId; + + var logout = await client.PostAsync("/auth/logout", null); + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + + var oldSessionResponse = await client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + oldSessionResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + + using var reloginClient = CreateClient(device); + var relogin = await LoginAsync(reloginClient, user.Identifier, user.Secret); + + var newCookie = GetSessionCookie(relogin); + reloginClient.DefaultRequestHeaders.Add("Cookie", newCookie); + + var afterResponse = await reloginClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + afterResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var after = await afterResponse.Content.ReadFromJsonAsync>(); + after.Should().NotBeNull(); + + var currentAfter = after!.Items.Single(x => x.IsCurrentDevice); + currentAfter.ChainId.Should().Be(chainId); + + currentAfter.IsRevoked.Should().BeFalse(); + currentAfter.RevokedAt.Should().BeNull(); + + currentAfter.ActiveSessionId.Should().NotBeNull(); + currentAfter.ActiveSessionId.Should().NotBe(sessionId); + } + + [Fact] + public async Task Logout_OldSession_ShouldRemainInvalidAfterLoginAgain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-old-session-{Guid.NewGuid():N}"; + + using var firstClient = CreateClient(device); + var firstLogin = await LoginAsync(firstClient, user.Identifier, user.Secret); + + var oldCookie = GetSessionCookie(firstLogin); + + firstClient.DefaultRequestHeaders.Add("Cookie", oldCookie); + + var logout = await firstClient.PostAsync("/auth/logout", null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + using var secondClient = CreateClient(device); + var secondLogin = await LoginAsync(secondClient, user.Identifier, user.Secret); + secondLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var newCookie = GetSessionCookie(secondLogin); + + newCookie.Should().NotBe(oldCookie); + + secondClient.DefaultRequestHeaders.Add("Cookie", newCookie); + + var newSessionResponse = await secondClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + + newSessionResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var replayClient = CreateClient(device); + replayClient.DefaultRequestHeaders.Add("Cookie", oldCookie); + + var replayResponse = await replayClient.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); + replayResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_ShouldDetachOnlyCurrentDeviceSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-device-1-{Guid.NewGuid():N}"; + var device2 = $"logout-device-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var login1 = await LoginAsync( + client1, + user.Identifier, + user.Secret); + + var login2 = await LoginAsync( + client2, + user.Identifier, + user.Secret); + + var cookie1 = GetSessionCookie(login1); + var cookie2 = GetSessionCookie(login2); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var beforeResponse = await client2.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + beforeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var device2ChainBefore = before!.Items.Single(x => x.IsCurrentDevice); + + var device2ChainId = device2ChainBefore.ChainId; + var device2SessionId = device2ChainBefore.ActiveSessionId; + + device2SessionId.Should().NotBeNull(); + + var logout = await client1.PostAsync("/auth/logout", null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + var device1Result = await client1.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest()); + + device1Result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var device2Result = await client2.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + device2Result.StatusCode.Should().Be(HttpStatusCode.OK); + + var after = await device2Result.Content.ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var device2ChainAfter = after!.Items.Single(x => x.IsCurrentDevice); + + device2ChainAfter.ChainId.Should().Be(device2ChainId); + device2ChainAfter.ActiveSessionId.Should().Be(device2SessionId); + device2ChainAfter.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Logout_ShouldNotRevokeDeviceChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-not-revoke-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + var cookie = GetSessionCookie(login); + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var beforeResponse = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var chainBefore = before!.Items.Single(x => x.IsCurrentDevice); + var chainId = chainBefore.ChainId; + + await client.PostAsync("/auth/logout", null); + + using var reloginClient = CreateClient(device); + + var relogin = await LoginAsync( + reloginClient, + user.Identifier, + user.Secret); + + var newCookie = GetSessionCookie(relogin); + + reloginClient.DefaultRequestHeaders.Add( + "Cookie", + newCookie); + + var afterResponse = await reloginClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var chainAfter = after!.Items.Single(x => x.IsCurrentDevice); + + chainAfter.ChainId.Should().Be(chainId); + chainAfter.IsRevoked.Should().BeFalse(); + chainAfter.RevokedAt.Should().BeNull(); + } + + [Fact] + public async Task Logout_WithoutAuthentication_ShouldBeSafeAndIdempotent() + { + _factory.Clock.Reset(); + + using var client = CreateClient( + $"logout-anonymous-{Guid.NewGuid():N}"); + + var first = await client.PostAsync( + "/auth/logout", + null); + + var second = await client.PostAsync( + "/auth/logout", + null); + + first.StatusCode.Should().Be(HttpStatusCode.Found); + second.StatusCode.Should().Be(HttpStatusCode.Found); + } + + [Fact] + public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainIdempotent() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-idempotent-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var firstLogout = await client.PostAsync( + "/auth/logout", + null); + + firstLogout.StatusCode.Should().Be(HttpStatusCode.Found); + + var secondLogout = await client.PostAsync( + "/auth/logout", + null); + + secondLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + var authenticatedRequest = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest()); + + authenticatedRequest.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_WithInvalidSessionCookie_ShouldNotAffectValidSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var validDevice = + $"logout-valid-{Guid.NewGuid():N}"; + + var invalidDevice = + $"logout-invalid-{Guid.NewGuid():N}"; + + using var validClient = CreateClient(validDevice); + using var invalidClient = CreateClient(invalidDevice); + + var login = await LoginAsync( + validClient, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var validCookie = GetSessionCookie(login); + + validClient.DefaultRequestHeaders.Add( + "Cookie", + validCookie); + + // + // Deliberately forged / nonexistent session credential. + // + invalidClient.DefaultRequestHeaders.Add( + "Cookie", + $"uas={Guid.NewGuid():N}"); + + var invalidLogout = await invalidClient.PostAsync( + "/auth/logout", + null); + + invalidLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + // + // Existing legitimate session must be untouched. + // + var validSession = await validClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + validSession.StatusCode.Should().Be(HttpStatusCode.OK); + + var chains = await validSession.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var current = chains!.Items.Single(x => x.IsCurrentDevice); + + current.ActiveSessionId.Should().NotBeNull(); + current.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Logout_WithOldSessionAfterRelogin_ShouldNotLogoutNewSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-stale-{Guid.NewGuid():N}"; + + // + // Session A + // + using var firstClient = CreateClient(device); + + var firstLogin = await LoginAsync( + firstClient, + user.Identifier, + user.Secret); + + firstLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var oldCookie = GetSessionCookie(firstLogin); + + firstClient.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var firstLogout = await firstClient.PostAsync( + "/auth/logout", + null); + + firstLogout.StatusCode.Should().Be(HttpStatusCode.Found); + + // + // Same device -> Session B + // + using var currentClient = CreateClient(device); + + var secondLogin = await LoginAsync( + currentClient, + user.Identifier, + user.Secret); + + secondLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var newCookie = GetSessionCookie(secondLogin); + + newCookie.Should().NotBe(oldCookie); + + currentClient.DefaultRequestHeaders.Add( + "Cookie", + newCookie); + + var beforeReplay = await currentClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + beforeReplay.StatusCode.Should().Be(HttpStatusCode.OK); + + var beforeChains = await beforeReplay.Content + .ReadFromJsonAsync>(); + + beforeChains.Should().NotBeNull(); + + var currentBefore = + beforeChains!.Items.Single(x => x.IsCurrentDevice); + + var currentChainId = currentBefore.ChainId; + var currentSessionId = currentBefore.ActiveSessionId; + + currentSessionId.Should().NotBeNull(); + + // + // Replay Session A against /auth/logout. + // + using var staleClient = CreateClient(device); + + staleClient.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var staleLogout = await staleClient.PostAsync( + "/auth/logout", + null); + + staleLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + // + // Session B MUST still be valid. + // + var afterReplay = await currentClient.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + afterReplay.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterChains = await afterReplay.Content + .ReadFromJsonAsync>(); + + afterChains.Should().NotBeNull(); + + var currentAfter = + afterChains!.Items.Single(x => x.IsCurrentDevice); + + currentAfter.ChainId.Should().Be(currentChainId); + currentAfter.ActiveSessionId.Should().Be(currentSessionId); + currentAfter.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task RepeatedLogoutLoginCycles_ShouldPreserveChainAndRotateSessions() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-cycle-{Guid.NewGuid():N}"; + + SessionChainId? expectedChainId = null; + var sessionIds = new List(); + + for (var i = 0; i < 3; i++) + { + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var chainsResponse = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var current = + chains!.Items.Single(x => x.IsCurrentDevice); + + current.ActiveSessionId.Should().NotBeNull(); + current.IsRevoked.Should().BeFalse(); + + if (expectedChainId is null) + { + expectedChainId = current.ChainId; + } + else + { + current.ChainId.Should().Be(expectedChainId.Value); + } + + sessionIds.Add(current.ActiveSessionId!.Value); + + // + // Keep final session active so we can inspect it. + // + if (i < 2) + { + var logout = await client.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + } + } + + sessionIds.Should().HaveCount(3); + + sessionIds.Distinct() + .Should() + .HaveCount(3); + } + + [Fact] + public async Task Logout_WithStaleCredential_ShouldNotAffectOtherDevice() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = + $"logout-stale-device-1-{Guid.NewGuid():N}"; + + var device2 = + $"logout-stale-device-2-{Guid.NewGuid():N}"; + + // + // Device 1 login. + // + using var device1Client = CreateClient(device1); + + var login1 = await LoginAsync( + device1Client, + user.Identifier, + user.Secret); + + var staleCookie = GetSessionCookie(login1); + + device1Client.DefaultRequestHeaders.Add( + "Cookie", + staleCookie); + + // + // Device 2 login. + // + using var device2Client = CreateClient(device2); + + var login2 = await LoginAsync( + device2Client, + user.Identifier, + user.Secret); + + var device2Cookie = GetSessionCookie(login2); + + device2Client.DefaultRequestHeaders.Add( + "Cookie", + device2Cookie); + + var device2BeforeResponse = + await device2Client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + device2BeforeResponse.StatusCode + .Should() + .Be(HttpStatusCode.OK); + + var before = await device2BeforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var device2Before = + before!.Items.Single(x => x.IsCurrentDevice); + + var device2ChainId = device2Before.ChainId; + var device2SessionId = device2Before.ActiveSessionId; + + device2SessionId.Should().NotBeNull(); + + var logout1 = await device1Client.PostAsync("/auth/logout", null); + + logout1.StatusCode.Should().Be(HttpStatusCode.Found); + + using var staleClient = CreateClient(device1); + + staleClient.DefaultRequestHeaders.Add("Cookie", staleCookie); + + var staleLogout = await staleClient.PostAsync("/auth/logout", null); + + staleLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + var device2AfterResponse = + await device2Client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + device2AfterResponse.StatusCode + .Should() + .Be(HttpStatusCode.OK); + + var after = await device2AfterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var device2After = + after!.Items.Single(x => x.IsCurrentDevice); + + device2After.ChainId.Should().Be(device2ChainId); + device2After.ActiveSessionId.Should().Be(device2SessionId); + device2After.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainSafe() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-idempotent-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add("Cookie", cookie); + + var firstLogout = await client.PostAsync("/auth/logout", null); + + firstLogout.StatusCode.Should().Be(HttpStatusCode.Found); + + var secondLogout = await client.PostAsync("/auth/logout", null); + + secondLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + var authenticatedRequest = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + authenticatedRequest.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_ShouldNotReturnAuthenticationCredentials() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-no-credentials-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var sessionCookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + sessionCookie); + + var logout = await client.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + // + // Logout may legitimately contain Set-Cookie headers whose purpose + // is credential deletion. + // + // It must not issue a new usable session credential. + // + if (logout.Headers.TryGetValues("Set-Cookie", out var setCookies)) + { + var sessionCookies = setCookies + .Where(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + sessionCookies.Should().NotBeEmpty(); + + sessionCookies.Should().OnlyContain( + x => + x.Contains( + "expires=", + StringComparison.OrdinalIgnoreCase) || + x.Contains( + "max-age=0", + StringComparison.OrdinalIgnoreCase)); + } + } + + [Fact] + public async Task Logout_ShouldDeleteSessionCookieWithRootPath() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-cookie-path-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var sessionCookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + sessionCookie); + + var logout = await client.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + logout.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should() + .BeTrue(); + + var deletionCookie = cookies! + .Single(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + + deletionCookie.Should().ContainEquivalentOf("path=/"); + + deletionCookie.Should().MatchRegex( + "(?i)(expires=|max-age=0)"); + } + + [Fact] + public async Task Logout_ShouldNotReissueSessionCookieAfterServerSideInvalidation() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-no-reissue-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var oldCookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var logout = await client.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + logout.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should() + .BeTrue(); + + var sessionCookieResponses = cookies! + .Where(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)) + .ToList(); + + sessionCookieResponses.Should().NotBeEmpty(); + + // + // Every uas emitted by logout must represent deletion. + // There must not be another Set-Cookie that creates a fresh uas. + // + sessionCookieResponses.Should().OnlyContain( + x => + x.Contains( + "expires=", + StringComparison.OrdinalIgnoreCase) || + x.Contains( + "max-age=0", + StringComparison.OrdinalIgnoreCase)); + + // + // And the original credential remains unusable server-side. + // + using var replayClient = CreateClient(device); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var replay = await GetChainsAsync(replayClient); + + replay.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Logout_CookieDeletion_ShouldNotContainOriginalSessionCredential() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-cookie-value-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var sessionCookie = GetSessionCookie(login); + + var originalValue = GetCookieValue( + sessionCookie, + "uas"); + + originalValue.Should().NotBeNullOrWhiteSpace(); + + client.DefaultRequestHeaders.Add( + "Cookie", + sessionCookie); + + var logout = await client.PostAsync( + "/auth/logout", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.Found); + + logout.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should() + .BeTrue(); + + var deletionCookie = cookies! + .Single(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + + // + // Logout response must never echo the old credential. + // + deletionCookie.Should().NotContain(originalValue!); + } + + [Fact] + public async Task ConcurrentLogout_WithSameSession_ShouldLeaveSessionInvalid() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-concurrent-same-{Guid.NewGuid():N}"; + + using var loginClient = CreateClient(device); + + var login = await LoginAsync( + loginClient, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + // + // Two independent requests carrying exactly the same authority. + // + using var client1 = CreateClient(device); + using var client2 = CreateClient(device); + + client1.DefaultRequestHeaders.Add("Cookie", cookie); + client2.DefaultRequestHeaders.Add("Cookie", cookie); + + var responses = await Task.WhenAll( + client1.PostAsync("/auth/logout", null), + client2.PostAsync("/auth/logout", null)); + + // + // Depending on where stale-session rejection occurs, + // one request may observe the session after the other revoked it. + // + // What must never happen is an infrastructure failure. + // + responses.Should().OnlyContain(x => + x.StatusCode == HttpStatusCode.Found || + x.StatusCode == HttpStatusCode.Unauthorized); + + // + // Final state is the actual security invariant. + // + using var replayClient = CreateClient(device); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var replay = await GetChainsAsync(replayClient); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ConcurrentLogout_FromDifferentDevices_ShouldInvalidateBothSessions() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = + $"logout-concurrent-device-1-{Guid.NewGuid():N}"; + + var device2 = + $"logout-concurrent-device-2-{Guid.NewGuid():N}"; + + using var loginClient1 = CreateClient(device1); + using var loginClient2 = CreateClient(device2); + + var logins = await Task.WhenAll( + LoginAsync( + loginClient1, + user.Identifier, + user.Secret), + LoginAsync( + loginClient2, + user.Identifier, + user.Secret)); + + logins.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Found); + + var cookie1 = GetSessionCookie(logins[0]); + var cookie2 = GetSessionCookie(logins[1]); + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + client1.DefaultRequestHeaders.Add( + "Cookie", + cookie1); + + client2.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + var logouts = await Task.WhenAll( + client1.PostAsync("/auth/logout", null), + client2.PostAsync("/auth/logout", null)); + + logouts.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Found); + + // + // Neither credential may survive. + // + using var replay1 = CreateClient(device1); + using var replay2 = CreateClient(device2); + + replay1.DefaultRequestHeaders.Add( + "Cookie", + cookie1); + + replay2.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + var verification = await Task.WhenAll( + GetChainsAsync(replay1), + GetChainsAsync(replay2)); + + verification.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ConcurrentRequests_WithSessionBeingLoggedOut_ShouldNeverLeaveCredentialUsableAfterLogout() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = + $"logout-concurrent-request-{Guid.NewGuid():N}"; + + using var loginClient = CreateClient(device); + + var login = await LoginAsync( + loginClient, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + using var logoutClient = CreateClient(device); + using var requestClient = CreateClient(device); + + logoutClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + requestClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + // + // Intentionally race an authenticated operation against logout. + // + var logoutTask = + logoutClient.PostAsync("/auth/logout", null); + + var authenticatedRequestTask = + GetChainsAsync(requestClient); + + var logout = await logoutTask; + var concurrentRequest = await authenticatedRequestTask; + + logout.StatusCode.Should().BeOneOf( + HttpStatusCode.Found, + HttpStatusCode.Unauthorized); + + // + // The concurrent request itself is allowed to observe either state. + // + // If it authenticated before logout linearized -> 200. + // If it authenticated afterwards -> 401. + // + concurrentRequest.StatusCode.Should().BeOneOf( + HttpStatusCode.OK, + HttpStatusCode.Unauthorized); + + // + // But AFTER logout has completed, there is no ambiguity. + // + using var verificationClient = CreateClient(device); + + verificationClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var verification = + await GetChainsAsync(verificationClient); + + verification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ConcurrentLogoutAndRelogin_OnSameDevice_ShouldLeaveChainInConsistentState() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device = + $"logout-relogin-race-{Guid.NewGuid():N}"; + + // + // Establish Session A. + // + using var initialClient = CreateClient(device); + + var initialLogin = await LoginAsync( + initialClient, + user.Identifier, + user.Secret); + + initialLogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var oldCookie = GetSessionCookie(initialLogin); + + // + // One request logs Session A out while another request performs + // a fresh login from the same device. + // + using var logoutClient = CreateClient(device); + using var loginClient = CreateClient(device); + + logoutClient.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var logoutTask = + logoutClient.PostAsync("/auth/logout", null); + + var loginTask = + LoginAsync( + loginClient, + user.Identifier, + user.Secret); + + await Task.WhenAll( + logoutTask, + loginTask); + + var logout = await logoutTask; + var relogin = await loginTask; + + // + // Neither operation may leak an infrastructure/concurrency failure. + // + logout.StatusCode.Should().BeOneOf( + HttpStatusCode.Found, + HttpStatusCode.Unauthorized); + + relogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var newCookie = GetSessionCookie(relogin); + + newCookie.Should().NotBeNullOrWhiteSpace(); + newCookie.Should().NotBe(oldCookie); + + // + // Session A must NEVER become usable again regardless of + // operation ordering. + // + using var oldSessionClient = CreateClient(device); + + oldSessionClient.DefaultRequestHeaders.Add( + "Cookie", + oldCookie); + + var oldSessionVerification = + await GetChainsAsync(oldSessionClient); + + oldSessionVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Session B has two legitimate outcomes: + // + // 200: + // logout linearized before the new login. + // + // 401: + // login attached Session B first and the concurrent + // chain logout subsequently invalidated it. + // + using var newSessionClient = CreateClient(device); + + newSessionClient.DefaultRequestHeaders.Add( + "Cookie", + newCookie); + + var newSessionVerification = + await GetChainsAsync(newSessionClient); + + newSessionVerification.StatusCode.Should().BeOneOf( + HttpStatusCode.OK, + HttpStatusCode.Unauthorized); + + // + // Most important recovery invariant: + // regardless of which operation won the race, another clean login + // must restore a usable authenticated session. + // + using var recoveryClient = CreateClient(device); + + var recoveryLogin = await LoginAsync( + recoveryClient, + user.Identifier, + user.Secret); + + recoveryLogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var recoveryCookie = + GetSessionCookie(recoveryLogin); + + using var authenticatedRecoveryClient = + CreateClient(device); + + authenticatedRecoveryClient.DefaultRequestHeaders.Add( + "Cookie", + recoveryCookie); + + var recoveryVerification = + await GetChainsAsync(authenticatedRecoveryClient); + + recoveryVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceSelf_ShouldRevokeTargetDeviceSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-device-self-1-{Guid.NewGuid():N}"; + var device2 = $"logout-device-self-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var cookie1 = GetSessionCookie( + await LoginAsync(client1, user.Identifier, user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync(client2, user.Identifier, user.Secret)); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var chainsResponse = await GetChainsAsync(client1); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var targetChain = page!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var logoutResponse = await client1.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + logoutResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using var verificationClient = CreateClient(device2); + verificationClient.DefaultRequestHeaders.Add("Cookie", cookie2); + + var verification = await GetChainsAsync(verificationClient); + + verification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutDeviceSelf_ShouldNotInvalidateCurrentDevice_WhenAnotherDeviceIsTargeted() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-device-keep-{Guid.NewGuid():N}"; + var device2 = $"logout-device-target-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var cookie1 = GetSessionCookie( + await LoginAsync(client1, user.Identifier, user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync(client2, user.Identifier, user.Secret)); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var chainsResponse = await GetChainsAsync(client1); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var targetChain = page!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var logout = await client1.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Actor's own session must remain usable. + // + var actorVerification = + await GetChainsAsync(client1); + + actorVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Target session must be dead. + // + using var targetVerificationClient = + CreateClient(device2); + + targetVerificationClient.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + var targetVerification = + await GetChainsAsync(targetVerificationClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutDeviceSelf_ShouldDetachActiveSessionWithoutRevokingChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-device-observer-{Guid.NewGuid():N}"; + var device2 = $"logout-device-revoked-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var cookie1 = GetSessionCookie(await LoginAsync(client1, user.Identifier, user.Secret)); + + var cookie2 = GetSessionCookie(await LoginAsync(client2, user.Identifier, user.Secret)); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var beforeResponse = await GetChainsAsync(client1); + + var before = await beforeResponse.Content.ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var target = before!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var logout = await client1.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = target.ChainId + }); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + var afterResponse = await GetChainsAsync(client1); + + afterResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var after = await afterResponse.Content.ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var loggedOut = after!.Items.Single(x => x.ChainId == target.ChainId); + + loggedOut.IsRevoked.Should().BeFalse(); + loggedOut.RevokedAt.Should().BeNull(); + loggedOut.ActiveSessionId.Should().BeNull(); + } + + [Fact] + public async Task LogoutDeviceSelf_WithoutAuthentication_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + using var client = CreateClient( + $"logout-device-anonymous-{Guid.NewGuid():N}"); + + var response = await client.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = SessionChainId.New() + }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutDeviceSelf_ShouldNotAllowUserToLogoutAnotherUsersDevice() + { + _factory.Clock.Reset(); + + var attacker = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + + var attackerDevice = + $"logout-device-attacker-{Guid.NewGuid():N}"; + + var victimDevice = + $"logout-device-victim-{Guid.NewGuid():N}"; + + using var attackerClient = CreateClient(attackerDevice); + using var victimClient = CreateClient(victimDevice); + + var attackerCookie = GetSessionCookie( + await LoginAsync( + attackerClient, + attacker.Identifier, + attacker.Secret)); + + var victimCookie = GetSessionCookie( + await LoginAsync( + victimClient, + victim.Identifier, + victim.Secret)); + + attackerClient.DefaultRequestHeaders.Add( + "Cookie", + attackerCookie); + + victimClient.DefaultRequestHeaders.Add( + "Cookie", + victimCookie); + + // + // Obtain victim's own chain id. + // + var victimChainsResponse = + await GetChainsAsync(victimClient); + + victimChainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var victimChains = await victimChainsResponse.Content + .ReadFromJsonAsync>(); + + victimChains.Should().NotBeNull(); + + var victimChain = victimChains!.Items + .Single(x => x.IsCurrentDevice); + + // + // Attacker tries to target victim's ChainId. + // + var attack = await attackerClient.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = victimChain.ChainId + }); + + attack.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Unauthorized); + + var victimVerification = await GetChainsAsync(victimClient); + + victimVerification.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceSelf_ShouldAllowCurrentDeviceToLogoutItself() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-device-current-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + login.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var chainsResponse = await GetChainsAsync(client); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var currentChain = chains!.Items + .Single(x => x.IsCurrentDevice); + + currentChain.ActiveSessionId.Should().NotBeNull(); + + var logout = await client.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = currentChain.ChainId + }); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // The authority that performed the operation must now be dead. + // + var verification = await GetChainsAsync(client); + + verification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutDeviceSelf_WithUnknownChain_ShouldNotAffectCurrentSession() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var device = $"logout-device-unknown-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var login = await LoginAsync( + client, + user.Identifier, + user.Secret); + + var cookie = GetSessionCookie(login); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var response = await client.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = SessionChainId.New() + }); + + // + // Exact mapping depends on the UltimateAuth exception pipeline. + // Unknown resources must never produce success through mutation. + // + response.StatusCode.Should().BeOneOf( + HttpStatusCode.NotFound, + HttpStatusCode.Forbidden); + + // + // Most important invariant: + // malformed/unknown target must not damage actor's session. + // + var verification = await GetChainsAsync(client); + + verification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutDeviceSelf_AlreadyLoggedOutDevice_ShouldRemainSafe() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var actorDevice = + $"logout-device-repeat-actor-{Guid.NewGuid():N}"; + + var targetDevice = + $"logout-device-repeat-target-{Guid.NewGuid():N}"; + + using var actorClient = CreateClient(actorDevice); + using var targetClient = CreateClient(targetDevice); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var chainsResponse = await GetChainsAsync(actorClient); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var targetChain = chains!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var first = await actorClient.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Same operation against an already detached chain. + // + var second = await actorClient.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = targetChain.ChainId + }); + + second.StatusCode.Should().BeOneOf( + HttpStatusCode.OK, + HttpStatusCode.NotFound); + + // + // Actor must survive the repeated operation. + // + var actorVerification = + await GetChainsAsync(actorClient); + + actorVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Target credential must remain dead. + // + using var replayClient = CreateClient(targetDevice); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var targetVerification = + await GetChainsAsync(replayClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutDeviceSelf_LoggedOutDevice_ShouldBeAbleToLoginAgain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var actorDevice = + $"logout-device-relogin-actor-{Guid.NewGuid():N}"; + + var targetDevice = + $"logout-device-relogin-target-{Guid.NewGuid():N}"; + + using var actorClient = CreateClient(actorDevice); + using var targetClient = CreateClient(targetDevice); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var oldTargetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + oldTargetCookie); + + var chainsResponse = + await GetChainsAsync(actorClient); + + var chains = await chainsResponse.Content + .ReadFromJsonAsync>(); + + chains.Should().NotBeNull(); + + var targetChain = chains!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var originalChainId = targetChain.ChainId; + var originalSessionId = targetChain.ActiveSessionId; + + var logout = await actorClient.PostAsJsonAsync( + "/auth/me/logout-device", + new LogoutDeviceRequest + { + ChainId = originalChainId + }); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Old credential must be dead. + // + using var oldCredentialClient = + CreateClient(targetDevice); + + oldCredentialClient.DefaultRequestHeaders.Add( + "Cookie", + oldTargetCookie); + + var oldVerification = + await GetChainsAsync(oldCredentialClient); + + oldVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // But logout-device is not device revocation. + // Correct credentials may establish a new session. + // + using var reloginClient = + CreateClient(targetDevice); + + var relogin = await LoginAsync( + reloginClient, + user.Identifier, + user.Secret); + + relogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var newCookie = GetSessionCookie(relogin); + + newCookie.Should().NotBe(oldTargetCookie); + + reloginClient.DefaultRequestHeaders.Add( + "Cookie", + newCookie); + + var afterResponse = + await GetChainsAsync(reloginClient); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var current = + after!.Items.Single(x => x.IsCurrentDevice); + + // + // Same physical/device chain should be reused. + // + current.ChainId.Should().Be(originalChainId); + + current.IsRevoked.Should().BeFalse(); + current.RevokedAt.Should().BeNull(); + + current.ActiveSessionId.Should().NotBeNull(); + current.ActiveSessionId.Should().NotBe(originalSessionId); + } + + [Fact] + public async Task LogoutOthersSelf_ShouldInvalidateAllOtherDeviceSessions() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-others-current-{Guid.NewGuid():N}"; + var device2 = $"logout-others-other-1-{Guid.NewGuid():N}"; + var device3 = $"logout-others-other-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + using var client3 = CreateClient(device3); + + var cookie1 = GetSessionCookie( + await LoginAsync(client1, user.Identifier, user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync(client2, user.Identifier, user.Secret)); + + var cookie3 = GetSessionCookie( + await LoginAsync(client3, user.Identifier, user.Secret)); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + client3.DefaultRequestHeaders.Add("Cookie", cookie3); + + // + // All three sessions must initially be usable. + // + (await GetChainsAsync(client1)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(client2)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(client3)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Device 1 logs out every OTHER device. + // + var logout = await client1.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Actor survives. + // + (await GetChainsAsync(client1)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Every other authority must be dead. + // + (await GetChainsAsync(client2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(client3)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutOthersSelf_ShouldKeepCurrentSessionValid() + { + _factory.Clock.Reset(); - _client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); + var user = await _factory.CreateLoginUserAsync(); + + var currentDevice = + $"logout-others-keep-current-{Guid.NewGuid():N}"; + + var otherDevice = + $"logout-others-target-{Guid.NewGuid():N}"; + + using var currentClient = CreateClient(currentDevice); + using var otherClient = CreateClient(otherDevice); + + var currentCookie = GetSessionCookie( + await LoginAsync( + currentClient, + user.Identifier, + user.Secret)); + + var otherCookie = GetSessionCookie( + await LoginAsync( + otherClient, + user.Identifier, + user.Secret)); + + currentClient.DefaultRequestHeaders.Add( + "Cookie", + currentCookie); + + otherClient.DefaultRequestHeaders.Add( + "Cookie", + otherCookie); + + // + // Capture actor authority before mutation. + // + var beforeResponse = + await GetChainsAsync(currentClient); + + beforeResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var currentBefore = + before!.Items.Single(x => x.IsCurrentDevice); + + var currentChainId = currentBefore.ChainId; + var currentSessionId = currentBefore.ActiveSessionId; + + currentSessionId.Should().NotBeNull(); + + var logout = await currentClient.PostAsync( + "/auth/me/logout-others", + null); - _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); - _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Actor credential remains usable. + // + var afterResponse = + await GetChainsAsync(currentClient); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var currentAfter = + after!.Items.Single(x => x.IsCurrentDevice); + + // + // logout-others must not rotate/detach actor authority. + // + currentAfter.ChainId.Should().Be(currentChainId); + currentAfter.ActiveSessionId.Should().Be(currentSessionId); + currentAfter.IsRevoked.Should().BeFalse(); + + // + // Other device must be invalid. + // + var otherVerification = + await GetChainsAsync(otherClient); + + otherVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); } [Fact] - public async Task Logout_Should_Invalidate_Session_And_Cookie() + public async Task LogoutOthersSelf_ShouldDetachOtherSessionsWithoutRevokingChains() { - var loginResponse = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + _factory.Clock.Reset(); - var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + var user = await _factory.CreateLoginUserAsync(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + var actorDevice = + $"logout-others-observer-{Guid.NewGuid():N}"; - var logoutResponse = await _client.PostAsync("/auth/logout", null); - logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + var targetDevice1 = + $"logout-others-target-1-{Guid.NewGuid():N}"; + + var targetDevice2 = + $"logout-others-target-2-{Guid.NewGuid():N}"; + + using var actorClient = CreateClient(actorDevice); + using var targetClient1 = CreateClient(targetDevice1); + using var targetClient2 = CreateClient(targetDevice2); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + user.Identifier, + user.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); - var meResponse = await _client.PostAsync("/auth/me/profile/get", null); + targetClient1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); - meResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + targetClient2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + var beforeResponse = + await GetChainsAsync(actorClient); + + beforeResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var targetChains = before!.Items + .Where(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null) + .ToList(); + + targetChains.Should().HaveCount(2); + + var targetChainIds = targetChains + .Select(x => x.ChainId) + .ToHashSet(); + + var logout = await actorClient.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Actor can inspect post-operation chain state. + // + var afterResponse = + await GetChainsAsync(actorClient); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var loggedOutChains = after!.Items + .Where(x => targetChainIds.Contains(x.ChainId)) + .ToList(); + + loggedOutChains.Should().HaveCount(2); + + loggedOutChains.Should().OnlyContain(x => + x.ActiveSessionId == null); + + // + // logout-others is session logout, not device revocation. + // + loggedOutChains.Should().OnlyContain(x => + !x.IsRevoked && + x.RevokedAt == null); + + // + // Old credentials are dead. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] - public async Task Logout_Should_Detach_Chain_And_Reattach_On_Next_Login() + public async Task LogoutOthersSelf_ShouldNotAffectAnotherUsersSessions() { - var loginResponse1 = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + + // + // Actor owns two devices so logout-others actually has work to do. + // + using var actorClient1 = CreateClient( + $"logout-others-actor-1-{Guid.NewGuid():N}"); + + using var actorClient2 = CreateClient( + $"logout-others-actor-2-{Guid.NewGuid():N}"); + + using var victimClient = CreateClient( + $"logout-others-victim-{Guid.NewGuid():N}"); + + var actorCookie1 = GetSessionCookie( + await LoginAsync( + actorClient1, + actor.Identifier, + actor.Secret)); + + var actorCookie2 = GetSessionCookie( + await LoginAsync( + actorClient2, + actor.Identifier, + actor.Secret)); + + var victimCookie = GetSessionCookie( + await LoginAsync( + victimClient, + victim.Identifier, + victim.Secret)); + + actorClient1.DefaultRequestHeaders.Add( + "Cookie", + actorCookie1); + + actorClient2.DefaultRequestHeaders.Add( + "Cookie", + actorCookie2); + + victimClient.DefaultRequestHeaders.Add( + "Cookie", + victimCookie); + + // + // Sanity check before destructive operation. + // + (await GetChainsAsync(victimClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + var logout = await actorClient1.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Actor's second device is expected to die. + // + (await GetChainsAsync(actorClient2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Critical ownership invariant: + // another user's authority must be untouched. + // + var victimVerification = + await GetChainsAsync(victimClient); + + victimVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } - loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found); + [Fact] + public async Task LogoutOthersSelf_WithoutAuthentication_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); - var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").First(); - cookie1.Should().NotBeNullOrWhiteSpace(); + using var client = CreateClient( + $"logout-others-anonymous-{Guid.NewGuid():N}"); - _client.DefaultRequestHeaders.Remove("Cookie"); - _client.DefaultRequestHeaders.Add("Cookie", cookie1); + var response = await client.PostAsync( + "/auth/me/logout-others", + null); - var logoutResponse = await _client.PostAsync("/auth/logout", null); - logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } - var unauthorizedChainsResponse = await _client.PostAsJsonAsync( - "/auth/me/sessions/chains", - new PageRequest()); + [Fact] + public async Task LogoutOthersSelf_WithNoOtherSessions_ShouldSucceed() + { + _factory.Clock.Reset(); - unauthorizedChainsResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var user = await _factory.CreateLoginUserAsync(); - _client.DefaultRequestHeaders.Remove("Cookie"); + var device = + $"logout-others-single-{Guid.NewGuid():N}"; - var loginResponse2 = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + using var client = CreateClient(device); - loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found); + var cookie = GetSessionCookie( + await LoginAsync( + client, + user.Identifier, + user.Secret)); - var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").First(); - cookie2.Should().NotBeNullOrWhiteSpace(); + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); - _client.DefaultRequestHeaders.Add("Cookie", cookie2); + var beforeResponse = + await GetChainsAsync(client); - var chainsResponse = await _client.PostAsJsonAsync( - "/auth/me/sessions/chains", - new PageRequest - { - PageNumber = 1, - PageSize = 50 - }); + beforeResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); - chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); - var page = await chainsResponse.Content.ReadFromJsonAsync>(); - page.Should().NotBeNull(); - page!.Items.Should().NotBeEmpty(); + before.Should().NotBeNull(); - var currentDeviceChains = page.Items.Where(x => x.IsCurrentDevice).ToList(); - currentDeviceChains.Should().HaveCount(1); + var currentBefore = + before!.Items.Single(x => x.IsCurrentDevice); - var current = currentDeviceChains.Single(); - current.ActiveSessionId.Should().NotBeNull(); - current.IsRevoked.Should().BeFalse(); + var sessionId = + currentBefore.ActiveSessionId; + + sessionId.Should().NotBeNull(); + + // + // There is nothing to terminate. + // + var logout = await client.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // No-op must not damage current authority. + // + var afterResponse = + await GetChainsAsync(client); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var currentAfter = + after!.Items.Single(x => x.IsCurrentDevice); + + currentAfter.ActiveSessionId.Should() + .Be(sessionId); + + currentAfter.IsRevoked.Should() + .BeFalse(); } [Fact] - public async Task Logout_From_One_Device_Should_Not_Affect_Other_Device() + public async Task LogoutOthersSelf_RepeatedCall_ShouldRemainSafe() { - var client1 = CreateClient("device-111111111111111111111111111111111111111111111111111111111111111"); + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"logout-others-repeat-actor-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"logout-others-repeat-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var first = await actorClient.PostAsync( + "/auth/me/logout-others", + null); + + first.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Nothing remains to logout except actor itself. + // + var second = await actorClient.PostAsync( + "/auth/me/logout-others", + null); + + second.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Actor survives repeated calls. + // + (await GetChainsAsync(actorClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Previously invalidated authority stays invalid. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } - var login1 = await client1.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + [Fact] + public async Task LogoutOthersSelf_OldCredentialsFromOtherDevices_ShouldRemainInvalid() + { + _factory.Clock.Reset(); - var cookie1 = login1.Headers.GetValues("Set-Cookie").First(); - client1.DefaultRequestHeaders.Add("Cookie", cookie1); + var user = await _factory.CreateLoginUserAsync(); - var client2 = CreateClient("device-2222222222222222222222222222222222222222222222222222222222222222"); + var actorDevice = + $"logout-others-replay-actor-{Guid.NewGuid():N}"; - var login2 = await client2.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var targetDevice = + $"logout-others-replay-target-{Guid.NewGuid():N}"; - var cookie2 = login2.Headers.GetValues("Set-Cookie").First(); - client2.DefaultRequestHeaders.Add("Cookie", cookie2); + using var actorClient = CreateClient(actorDevice); + using var targetClient = CreateClient(targetDevice); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var oldTargetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + oldTargetCookie); + + var logout = await actorClient.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Don't verify only through the original HttpClient. + // Replay the exact authority from an independent request source. + // + using var replayClient = + CreateClient(targetDevice); - await client1.PostAsync("/auth/logout", null); + replayClient.DefaultRequestHeaders.Add( + "Cookie", + oldTargetCookie); - var me1 = await client1.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = ProfileKey.Default }); - me1.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var replay = + await GetChainsAsync(replayClient); - var me2 = await client2.PostAsJsonAsync("/auth/me/profile/get", new GetProfileRequest() { ProfileKey = ProfileKey.Default }); - me2.StatusCode.Should().Be(HttpStatusCode.OK); + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Actor remains valid. + // + (await GetChainsAsync(actorClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] - public async Task Logout_Should_Not_Revoke_Chain() + public async Task LogoutOthersSelf_LoggedOutDevices_ShouldBeAbleToLoginAgain() { - var login = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + _factory.Clock.Reset(); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + var user = await _factory.CreateLoginUserAsync(); - await _client.PostAsync("/auth/logout", null); + var actorDevice = + $"logout-others-relogin-actor-{Guid.NewGuid():N}"; - _client.DefaultRequestHeaders.Remove("Cookie"); + var targetDevice = + $"logout-others-relogin-target-{Guid.NewGuid():N}"; - var login2 = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + using var actorClient = CreateClient(actorDevice); + using var targetClient = CreateClient(targetDevice); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var beforeResponse = + await GetChainsAsync(actorClient); + + beforeResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var targetBefore = before!.Items.Single(x => + !x.IsCurrentDevice && + x.ActiveSessionId is not null); + + var originalChainId = + targetBefore.ChainId; + + var originalSessionId = + targetBefore.ActiveSessionId; + + var logout = await actorClient.PostAsync( + "/auth/me/logout-others", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); - var cookie2 = login2.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie2); + // + // Previous authority is dead. + // + using var oldCredentialClient = + CreateClient(targetDevice); - var chainsResponse = await _client.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); - var page = await chainsResponse.Content.ReadFromJsonAsync>(); + oldCredentialClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); - page!.Items.Should().Contain(x => x.IsCurrentDevice); + (await GetChainsAsync(oldCredentialClient)) + .StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); - var chain = page.Items.Single(); + // + // But the device itself was not revoked. + // + using var reloginClient = + CreateClient(targetDevice); + + var relogin = await LoginAsync( + reloginClient, + user.Identifier, + user.Secret); + + relogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var newCookie = + GetSessionCookie(relogin); + + newCookie.Should().NotBe(targetCookie); + + reloginClient.DefaultRequestHeaders.Add( + "Cookie", + newCookie); + + var afterResponse = + await GetChainsAsync(reloginClient); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + var currentAfter = + after!.Items.Single(x => x.IsCurrentDevice); + + // + // Chain survives logout-others. + // + currentAfter.ChainId.Should() + .Be(originalChainId); + + currentAfter.IsRevoked.Should() + .BeFalse(); + + currentAfter.RevokedAt.Should() + .BeNull(); + + // + // Session authority is new. + // + currentAfter.ActiveSessionId.Should() + .NotBeNull(); + + currentAfter.ActiveSessionId.Should() + .NotBe(originalSessionId); + } - chain.IsRevoked.Should().BeFalse(); + [Fact] + public async Task ConcurrentLogoutOthersSelf_ShouldLeaveOnlyActorSessionUsable() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var actorDevice = $"logout-others-concurrent-actor-{Guid.NewGuid():N}"; + + var targetDevice1 = $"logout-others-concurrent-target-1-{Guid.NewGuid():N}"; + + var targetDevice2 = $"logout-others-concurrent-target-2-{Guid.NewGuid():N}"; + + using var actorClient = CreateClient(actorDevice); + using var targetClient1 = CreateClient(targetDevice1); + using var targetClient2 = CreateClient(targetDevice2); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + user.Identifier, + user.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + user.Identifier, + user.Secret)); + + // + // Two independent requests carry the same actor authority. + // + using var request1 = CreateClient(actorDevice); + using var request2 = CreateClient(actorDevice); + + request1.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + request2.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + var responses = await Task.WhenAll( + request1.PostAsync( + "/auth/me/logout-others", + null), + + request2.PostAsync( + "/auth/me/logout-others", + null)); + + // + // Operation is conceptually idempotent. + // + responses.Should().OnlyContain(x => + x.StatusCode == HttpStatusCode.OK); + + // + // Actor authority survives. + // + using var actorVerification = + CreateClient(actorDevice); + + actorVerification.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + (await GetChainsAsync(actorVerification)) + .StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Every other authority must converge to invalid. + // + using var targetVerification1 = + CreateClient(targetDevice1); + + using var targetVerification2 = + CreateClient(targetDevice2); + + targetVerification1.DefaultRequestHeaders.Add( + "Cookie", + targetCookie1); + + targetVerification2.DefaultRequestHeaders.Add( + "Cookie", + targetCookie2); + + var verification = await Task.WhenAll( + GetChainsAsync(targetVerification1), + GetChainsAsync(targetVerification2)); + + verification.Should().OnlyContain(x => + x.StatusCode == HttpStatusCode.Unauthorized); } [Fact] - public async Task Logout_Should_Clear_Only_Current_Chain() + public async Task LogoutAllSelf_ShouldInvalidateAllDeviceSessions() { - var client1 = CreateClient("device-111111111111111111111111111111111111111111111111111111111"); - var client2 = CreateClient("device-222222222222222222222222222222222222222222222222222222222"); + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = $"logout-all-device-1-{Guid.NewGuid():N}"; + var device2 = $"logout-all-device-2-{Guid.NewGuid():N}"; + var device3 = $"logout-all-device-3-{Guid.NewGuid():N}"; - var cookie1 = await LoginAndGetCookie(client1); - var cookie2 = await LoginAndGetCookie(client2); + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + using var client3 = CreateClient(device3); + + var cookie1 = GetSessionCookie( + await LoginAsync(client1, user.Identifier, user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync(client2, user.Identifier, user.Secret)); + + var cookie3 = GetSessionCookie( + await LoginAsync(client3, user.Identifier, user.Secret)); client1.DefaultRequestHeaders.Add("Cookie", cookie1); client2.DefaultRequestHeaders.Add("Cookie", cookie2); + client3.DefaultRequestHeaders.Add("Cookie", cookie3); + + // + // Sanity check: every authority is initially valid. + // + (await GetChainsAsync(client1)) + .StatusCode.Should().Be(HttpStatusCode.OK); - await client1.PostAsync("/auth/logout", null); + (await GetChainsAsync(client2)) + .StatusCode.Should().Be(HttpStatusCode.OK); - var chainsResponse = await client2.PostAsJsonAsync("/auth/me/sessions/chains", new PageRequest()); - var page = await chainsResponse.Content.ReadFromJsonAsync>(); + (await GetChainsAsync(client3)) + .StatusCode.Should().Be(HttpStatusCode.OK); - page!.Items.Count(x => x.ActiveSessionId != null).Should().Be(1); + // + // Device 1 logs out ALL sessions belonging to the user, + // including its own session. + // + var logout = await client1.PostAsync( + "/auth/me/logout-all", + null); - var device2Chain = page.Items.First(x => x.IsCurrentDevice); + logout.StatusCode.Should().Be(HttpStatusCode.OK); - device2Chain.ActiveSessionId.Should().NotBeNull(); + // + // Every previously issued authority must now be dead. + // + (await GetChainsAsync(client1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(client2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(client3)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] - public async Task Logout_Should_Delete_Cookie() + public async Task LogoutAllSelf_ShouldInvalidateCallingSession() { - var login = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device = + $"logout-all-current-{Guid.NewGuid():N}"; + + using var client = CreateClient(device); + + var cookie = GetSessionCookie( + await LoginAsync( + client, + user.Identifier, + user.Secret)); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); - var cookie = login.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + // + // Actor is authenticated before logout-all. + // + var before = await GetChainsAsync(client); - var logout = await _client.PostAsync("/auth/logout", null); + before.StatusCode.Should() + .Be(HttpStatusCode.OK); - logout.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + // + // Unlike logout-others, logout-all includes the actor. + // + var logout = await client.PostAsync( + "/auth/me/logout-all", + null); - var logoutCookie = cookies!.First(); + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); - logoutCookie.Should().Contain("expires="); + // + // The same authority cannot be used again. + // + var after = await GetChainsAsync(client); + + after.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Verify replay independently from the original HttpClient. + // + using var replayClient = + CreateClient(device); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var replay = await GetChainsAsync(replayClient); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); } [Fact] - public async Task Logout_Twice_Should_Be_Idempotent() + public async Task LogoutAllSelf_ShouldDetachSessionsWithoutRevokingChains() { - var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = + $"logout-all-chain-1-{Guid.NewGuid():N}"; + + var device2 = + $"logout-all-chain-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var cookie1 = GetSessionCookie( + await LoginAsync( + client1, + user.Identifier, + user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + client2, + user.Identifier, + user.Secret)); + + client1.DefaultRequestHeaders.Add( + "Cookie", + cookie1); + + client2.DefaultRequestHeaders.Add( + "Cookie", + cookie2); + + // + // Capture both chains before logout-all because afterwards + // neither old session may be used for inspection. + // + var beforeResponse = + await GetChainsAsync(client1); + + beforeResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var activeBefore = before!.Items + .Where(x => x.ActiveSessionId is not null) + .ToList(); + + activeBefore.Should().HaveCount(2); + + var originalChains = activeBefore + .ToDictionary( + x => x.ChainId, + x => x.ActiveSessionId); + + var logout = await client1.PostAsync( + "/auth/me/logout-all", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Both old credentials are now dead. + // + (await GetChainsAsync(client1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(client2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Re-login from device1 so that we regain an authenticated + // observer capable of inspecting the user's chains. + // + using var observer = CreateClient(device1); + + var relogin = await LoginAsync( + observer, + user.Identifier, + user.Secret); + + relogin.StatusCode.Should() + .Be(HttpStatusCode.Found); + + var observerCookie = + GetSessionCookie(relogin); + + observer.DefaultRequestHeaders.Add( + "Cookie", + observerCookie); + + var afterResponse = + await GetChainsAsync(observer); + + afterResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var after = await afterResponse.Content + .ReadFromJsonAsync>(); + + after.Should().NotBeNull(); + + foreach (var original in originalChains) { - identifier = "admin", - secret = "admin" - }); + var chain = after!.Items.Single(x => + x.ChainId == original.Key); + + // + // logout-all is NOT chain/device revocation. + // + chain.IsRevoked.Should().BeFalse(); + chain.RevokedAt.Should().BeNull(); + + if (chain.IsCurrentDevice) + { + // + // device1 has logged in again, therefore its chain + // now owns a NEW session. + // + chain.ActiveSessionId.Should().NotBeNull(); + chain.ActiveSessionId.Should().NotBe(original.Value); + } + else + { + // + // device2 has not logged in again. + // + chain.ActiveSessionId.Should().BeNull(); + } + } + } + + [Fact] + public async Task LogoutAllSelf_ShouldNotAffectAnotherUsersSessions() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + + using var actorClient1 = CreateClient( + $"logout-all-actor-1-{Guid.NewGuid():N}"); + + using var actorClient2 = CreateClient( + $"logout-all-actor-2-{Guid.NewGuid():N}"); + + using var victimClient = CreateClient( + $"logout-all-victim-{Guid.NewGuid():N}"); + + var actorCookie1 = GetSessionCookie( + await LoginAsync( + actorClient1, + actor.Identifier, + actor.Secret)); + + var actorCookie2 = GetSessionCookie( + await LoginAsync( + actorClient2, + actor.Identifier, + actor.Secret)); + + var victimCookie = GetSessionCookie( + await LoginAsync( + victimClient, + victim.Identifier, + victim.Secret)); + + actorClient1.DefaultRequestHeaders.Add( + "Cookie", + actorCookie1); + + actorClient2.DefaultRequestHeaders.Add( + "Cookie", + actorCookie2); + + victimClient.DefaultRequestHeaders.Add( + "Cookie", + victimCookie); + + // + // Victim authority is valid before actor operation. + // + (await GetChainsAsync(victimClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + var logout = await actorClient1.PostAsync( + "/auth/me/logout-all", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Both actor sessions must die. + // + (await GetChainsAsync(actorClient1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(actorClient2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Critical ownership invariant: + // User B must remain completely unaffected. + // + var victimVerification = + await GetChainsAsync(victimClient); + + victimVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task LogoutAllSelf_WithoutAuthentication_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + using var client = CreateClient( + $"logout-all-anonymous-{Guid.NewGuid():N}"); + + var response = await client.PostAsync( + "/auth/me/logout-all", + null); - var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); - _client.DefaultRequestHeaders.Add("Cookie", cookie); + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task LogoutAllSelf_LoggedOutDevices_ShouldBeAbleToLoginAgain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var device1 = + $"logout-all-relogin-1-{Guid.NewGuid():N}"; + + var device2 = + $"logout-all-relogin-2-{Guid.NewGuid():N}"; + + using var client1 = CreateClient(device1); + using var client2 = CreateClient(device2); + + var oldCookie1 = GetSessionCookie( + await LoginAsync( + client1, + user.Identifier, + user.Secret)); + + var oldCookie2 = GetSessionCookie( + await LoginAsync( + client2, + user.Identifier, + user.Secret)); + + client1.DefaultRequestHeaders.Add( + "Cookie", + oldCookie1); + + client2.DefaultRequestHeaders.Add( + "Cookie", + oldCookie2); + + var beforeResponse = + await GetChainsAsync(client1); + + var before = await beforeResponse.Content + .ReadFromJsonAsync>(); + + before.Should().NotBeNull(); + + var originalChains = before!.Items + .Where(x => x.ActiveSessionId is not null) + .ToDictionary( + x => x.ChainId, + x => x.ActiveSessionId); + + originalChains.Should().HaveCount(2); + + var logout = await client1.PostAsync( + "/auth/me/logout-all", + null); + + logout.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Old authorities must be unusable. + // + using var replay1 = CreateClient(device1); + using var replay2 = CreateClient(device2); + + replay1.DefaultRequestHeaders.Add( + "Cookie", + oldCookie1); + + replay2.DefaultRequestHeaders.Add( + "Cookie", + oldCookie2); + + (await GetChainsAsync(replay1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(replay2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Logout-all must not ban/revoke the devices themselves. + // + using var relogin1 = CreateClient(device1); + using var relogin2 = CreateClient(device2); + + var login1 = await LoginAsync( + relogin1, + user.Identifier, + user.Secret); + + var login2 = await LoginAsync( + relogin2, + user.Identifier, + user.Secret); + + login1.StatusCode.Should().Be(HttpStatusCode.Found); + login2.StatusCode.Should().Be(HttpStatusCode.Found); + + var newCookie1 = GetSessionCookie(login1); + var newCookie2 = GetSessionCookie(login2); + + newCookie1.Should().NotBe(oldCookie1); + newCookie2.Should().NotBe(oldCookie2); + + relogin1.DefaultRequestHeaders.Add( + "Cookie", + newCookie1); + + relogin2.DefaultRequestHeaders.Add( + "Cookie", + newCookie2); - var first = await _client.PostAsync("/auth/logout", null); - var second = await _client.PostAsync("/auth/logout", null); + (await GetChainsAsync(relogin1)) + .StatusCode.Should().Be(HttpStatusCode.OK); - second.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + (await GetChainsAsync(relogin2)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ConcurrentLogoutAllSelf_ShouldConvergeToAllSessionsInvalid() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var actorDevice = + $"logout-all-concurrent-actor-{Guid.NewGuid():N}"; + + var otherDevice = + $"logout-all-concurrent-other-{Guid.NewGuid():N}"; + + using var actorClient = CreateClient(actorDevice); + using var otherClient = CreateClient(otherDevice); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + user.Identifier, + user.Secret)); + + var otherCookie = GetSessionCookie( + await LoginAsync( + otherClient, + user.Identifier, + user.Secret)); + + // + // Two independent HTTP requests start with the same + // authenticated actor authority. + // + using var request1 = CreateClient(actorDevice); + using var request2 = CreateClient(actorDevice); + + request1.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + request2.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + var responses = await Task.WhenAll( + request1.PostAsync( + "/auth/me/logout-all", + null), + + request2.PostAsync( + "/auth/me/logout-all", + null)); + + // + // Depending on where authentication is linearized, + // the second request may observe either the pre-logout + // or post-logout authority. + // + responses.Should().OnlyContain(x => + x.StatusCode == HttpStatusCode.OK || + x.StatusCode == HttpStatusCode.Unauthorized); + + responses.Should().Contain(x => + x.StatusCode == HttpStatusCode.OK); + + // + // Final state is the security invariant that matters: + // no old authority may survive. + // + using var actorVerification = + CreateClient(actorDevice); + + using var otherVerification = + CreateClient(otherDevice); + + actorVerification.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + otherVerification.DefaultRequestHeaders.Add( + "Cookie", + otherCookie); + + var verification = await Task.WhenAll( + GetChainsAsync(actorVerification), + GetChainsAsync(otherVerification)); + + verification.Should().OnlyContain(x => + x.StatusCode == HttpStatusCode.Unauthorized); } + // ------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------ + private HttpClient CreateClient(string deviceId) { - var client = _factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); client.DefaultRequestHeaders.Add("X-UDID", deviceId); @@ -257,14 +3591,63 @@ private HttpClient CreateClient(string deviceId) return client; } - private async Task LoginAndGetCookie(HttpClient client) + private static Task LoginAsync(HttpClient client, string identifier, string secret) + { + return client.PostAsJsonAsync( + "/auth/login", + new + { + identifier, + secret + }); + } + + private static Task GetChainsAsync(HttpClient client) + { + return client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + } + + private static string GetSessionCookie(HttpResponseMessage response) + { + response.Headers + .TryGetValues("Set-Cookie", out var cookies) + .Should() + .BeTrue(); + + cookies.Should().NotBeNullOrEmpty(); + + var cookie = cookies!.First(); + cookie.Should().NotBeNullOrWhiteSpace(); + + return cookie; + } + + private static string? GetCookieValue(string setCookie, string cookieName) { - var response = await client.PostAsJsonAsync("/auth/login", new + var firstSegment = setCookie + .Split(';', 2)[0]; + + var separator = firstSegment.IndexOf('='); + + if (separator < 0) + return null; + + var name = firstSegment[..separator]; + + if (!string.Equals( + name, + cookieName, + StringComparison.OrdinalIgnoreCase)) { - identifier = "admin", - secret = "admin" - }); + return null; + } - return response.Headers.GetValues("Set-Cookie").First(); + return firstSegment[(separator + 1)..]; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs index dfd73b27..1a356583 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RefreshTests.cs @@ -1,5 +1,11 @@ -using FluentAssertions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using System.Net; using System.Net.Http.Json; @@ -7,165 +13,2533 @@ namespace CodeBeam.UltimateAuth.Tests.Integration; public class RefreshTests : IClassFixture { + private readonly AuthServerFactory _factory; private readonly HttpClient _client; public RefreshTests(AuthServerFactory factory) { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions { - AllowAutoRedirect = false, - HandleCookies = false + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + _client.DefaultRequestHeaders.Add( + "X-UDID", + "test-device-1234567890123456"); + } + + [Fact] + public async Task Refresh_PureOpaque_BeforeTouchInterval_ShouldNotMutateChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + await LoginAsync(user, "BlazorServer"); + + var before = + await GetCurrentChainAsync(_client); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var after = + await GetCurrentChainAsync(_client); + + after.ChainId.Should() + .Be(before.ChainId); + + after.TouchCount.Should() + .Be(before.TouchCount); + + after.LastSeenAt.Should() + .Be(before.LastSeenAt); + + after.IsRevoked.Should() + .BeFalse(); + } + + [Fact] + public async Task Refresh_PureOpaque_AfterTouchInterval_ShouldTouchChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + await LoginAsync(user, "BlazorServer"); + + var before = + await GetCurrentChainAsync(_client); + + _factory.Clock.Advance( + TimeSpan.FromHours(1)); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var after = + await GetCurrentChainAsync(_client); + + after.ChainId.Should() + .Be(before.ChainId); + + after.TouchCount.Should() + .Be(before.TouchCount + 1); + + after.LastSeenAt.Should() + .NotBeNull(); + + after.LastSeenAt.Should() + .BeAfter(before.LastSeenAt!.Value); + + after.IsRevoked.Should() + .BeFalse(); + } + + [Fact] + public async Task Refresh_PureOpaque_AfterTouch_ShouldNotTouchAgainBeforeNextInterval() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + await LoginAsync(user, "BlazorServer"); + + var initial = + await GetCurrentChainAsync(_client); + + _factory.Clock.Advance( + TimeSpan.FromHours(1)); + + var first = + await RefreshAsync(); + + first.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var afterFirst = + await GetCurrentChainAsync(_client); + + afterFirst.TouchCount.Should() + .Be(initial.TouchCount + 1); + + var second = + await RefreshAsync(); + + second.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var afterSecond = + await GetCurrentChainAsync(_client); + + afterSecond.ChainId.Should() + .Be(afterFirst.ChainId); + + afterSecond.TouchCount.Should() + .Be(afterFirst.TouchCount); + + afterSecond.LastSeenAt.Should() + .Be(afterFirst.LastSeenAt); + } + + [Fact] + public async Task Refresh_PureOpaque_ShouldPreserveSessionAndChainIdentity() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + await LoginAsync(user, "BlazorServer"); + + var before = + await GetCurrentChainAsync(_client); + + before.ActiveSessionId.Should() + .NotBeNull(); + + var sessionIdBefore = + before.ActiveSessionId; + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var after = + await GetCurrentChainAsync(_client); + + after.ChainId.Should() + .Be(before.ChainId); + + after.ActiveSessionId.Should() + .Be(sessionIdBefore); + } + + [Fact] + public async Task Refresh_PureOpaque_WithoutSession_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + SetClientProfile("BlazorServer"); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_WithRevokedSession_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorServer"); + + var current = + await GetCurrentChainAsync(_client); + + current.ActiveSessionId.Should() + .NotBeNull(); + + var sessionId = + current.ActiveSessionId!.Value; + + var store = + GetSessionStore(); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + sessionId, + _factory.Clock.UtcNow, + ct)); + + var persisted = + await store.GetSessionAsync(sessionId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_WithRevokedChain_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorServer"); + + var current = + await GetCurrentChainAsync(_client); + + var store = + GetSessionStore(); + + await store.ExecuteAsync( + ct => store.RevokeChainCascadeAsync( + current.ChainId, + _factory.Clock.UtcNow, + ct)); + + var persistedChain = + await store.GetChainAsync( + current.ChainId); + + persistedChain.Should().NotBeNull(); + persistedChain!.IsRevoked.Should().BeTrue(); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_WithRevokedRoot_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorServer"); + + var current = + await GetCurrentChainAsync(_client); + + var store = + GetSessionStore(); + + var rootBefore = + await store.GetActiveRootByUserAsync( + user.UserKey); + + rootBefore.Should().NotBeNull(); + rootBefore!.IsRevoked.Should().BeFalse(); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + user.UserKey, + _factory.Clock.UtcNow, + ct)); + + // + // Active lookup must no longer return the revoked root. + // + var activeRootAfter = + await store.GetActiveRootByUserAsync( + user.UserKey); + + activeRootAfter.Should().BeNull(); + + // + // Historical root must still exist for audit/history. + // + var historicalRoot = + await store.GetRootByIdAsync( + rootBefore.RootId); + + historicalRoot.Should().NotBeNull(); + historicalRoot!.IsRevoked.Should().BeTrue(); + + // + // Cascade must also invalidate the authentication graph. + // + var chainAfter = + await store.GetChainAsync( + current.ChainId); + + chainAfter.Should().NotBeNull(); + chainAfter!.IsRevoked.Should().BeTrue(); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_PureOpaque_FromDifferentDevice_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var cookie = + await LoginAsync( + user, + "BlazorServer"); + + var originalChain = + await GetCurrentChainAsync(_client); + + originalChain.IsRevoked.Should() + .BeFalse(); + + using var otherClient = + _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + otherClient.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + otherClient.DefaultRequestHeaders.Add( + "X-UDID", + $"different-device-{Guid.NewGuid():N}"); + + otherClient.DefaultRequestHeaders.Add( + "X-UAuth-ClientProfile", + "BlazorServer"); + + otherClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var response = + await otherClient.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // A failed device-binding attempt must not damage + // the legitimate session. + // + var legitimateVerification = + await RefreshAsync(); + + legitimateVerification.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var chainAfter = + await GetCurrentChainAsync(_client); + + chainAfter.ChainId.Should() + .Be(originalChain.ChainId); + + chainAfter.IsRevoked.Should() + .BeFalse(); + } + + [Fact] + public async Task Refresh_Hybrid_ShouldRotateCredentials() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var beforeCookie = + await LoginAsync( + user, + "BlazorWasm"); + + var before = + await GetCurrentChainAsync(_client); + + before.ActiveSessionId.Should() + .NotBeNull(); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + response.Headers.TryGetValues( + "Set-Cookie", + out var setCookies) + .Should() + .BeTrue(); + + setCookies.Should() + .NotBeNull(); + + setCookies!.Should() + .NotBeEmpty(); + + var rotatedCookie = + BuildCookieHeader(response); + + rotatedCookie.Should() + .NotBeNullOrWhiteSpace(); + + rotatedCookie.Should() + .NotBe(beforeCookie); + } + + [Fact] + public async Task Refresh_Hybrid_ShouldPreserveChainIdentity() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorWasm"); + + var before = + await GetCurrentChainAsync(_client); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + ApplyResponseCookies(response); + + var after = + await GetCurrentChainAsync(_client); + + after.ChainId.Should() + .Be(before.ChainId); + + after.IsRevoked.Should() + .BeFalse(); + } + + [Fact] + public async Task Refresh_Hybrid_RotatedCredential_ShouldBeUsableForNextRefresh() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorWasm"); + + var before = + await GetCurrentChainAsync(_client); + + var first = + await RefreshAsync(); + + first.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + ApplyResponseCookies(first); + + var second = + await RefreshAsync(); + + second.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + ApplyResponseCookies(second); + + var after = + await GetCurrentChainAsync(_client); + + after.ChainId.Should() + .Be(before.ChainId); + + after.IsRevoked.Should() + .BeFalse(); + } + + [Fact] + public async Task Refresh_Hybrid_ReusingPreviousCredential_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var originalCookie = + await LoginAsync( + user, + "BlazorWasm"); + + var first = + await RefreshAsync(); + + first.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var rotatedCookie = + BuildCookieHeader(first); + + rotatedCookie.Should() + .NotBeNullOrWhiteSpace(); + + rotatedCookie.Should() + .NotBe(originalCookie); + + // + // Replay the credentials that were valid before rotation. + // + _client.DefaultRequestHeaders.Remove( + "Cookie"); + + _client.DefaultRequestHeaders.Add( + "Cookie", + originalCookie); + + var replay = + await RefreshAsync(); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WithoutRefreshToken_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var cookie = + await LoginAsync( + user, + "BlazorWasm"); + + var sessionCookie = cookie + .Split("; ", StringSplitOptions.RemoveEmptyEntries) + .Single(x => x.StartsWith("uas=", StringComparison.Ordinal)); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add( + "Cookie", + sessionCookie); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WithRefreshTokenFromDifferentSession_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var clientA = + CreateClient( + $"hybrid-session-a-{Guid.NewGuid():N}"); + + using var clientB = + CreateClient( + $"hybrid-session-b-{Guid.NewGuid():N}"); + + var cookieA = + await LoginAsync( + clientA, + user, + "BlazorWasm"); + + var cookieB = + await LoginAsync( + clientB, + user, + "BlazorWasm"); + + var sessionB = + GetCookie(cookieB, "uas"); + + var refreshA = + GetCookie(cookieA, "uar"); + + sessionB.Should().NotBeNullOrWhiteSpace(); + refreshA.Should().NotBeNullOrWhiteSpace(); + + clientB.DefaultRequestHeaders.Remove("Cookie"); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + $"{sessionB}; {refreshA}"); + + var response = + await clientB.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_FromDifferentDevice_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + using var originalClient = + CreateClient( + $"hybrid-original-{Guid.NewGuid():N}"); + + using var foreignClient = + CreateClient( + $"hybrid-foreign-{Guid.NewGuid():N}"); + + var cookie = + await LoginAsync( + originalClient, + user, + "BlazorWasm"); + + foreignClient.DefaultRequestHeaders.Remove("Cookie"); + + foreignClient.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var response = + await foreignClient.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Failed attack must not invalidate + // the legitimate credential set. + // + var legitimateRefresh = + await originalClient.PostAsync( + "/auth/refresh", + null); + + legitimateRefresh.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_WithRevokedSession_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorWasm"); + + var chain = + await GetCurrentChainAsync(_client); + + chain.ActiveSessionId.Should() + .NotBeNull(); + + await RevokeSessionDirectlyAsync( + chain.ActiveSessionId!.Value); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WithRevokedChain_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorWasm"); + + var chain = + await GetCurrentChainAsync(_client); + + await RevokeChainDirectlyAsync( + chain.ChainId); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WithRevokedRoot_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + await LoginAsync( + user, + "BlazorWasm"); + + await RevokeRootDirectlyAsync( + user.UserKey); + + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_ConcurrentReuseOfSameRefreshToken_ShouldAllowExactlyOneRequest() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-race-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); + + using var clientA = + CreateClient(deviceId); + + using var clientB = + CreateClient(deviceId); + + SetClientProfile(clientA, "BlazorWasm"); + SetClientProfile(clientB, "BlazorWasm"); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var responses = + await Task.WhenAll( + clientA.PostAsync("/auth/refresh", null), + clientB.PostAsync("/auth/refresh", null)); + + responses.Count(x => + x.StatusCode == HttpStatusCode.NoContent) + .Should() + .Be(1); + + responses.Count(x => + x.StatusCode == HttpStatusCode.Unauthorized) + .Should() + .Be(1); + } + + [Fact] + public async Task Refresh_Hybrid_ConcurrentDuplicate_ShouldLeaveWinningCredentialUsable() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Token.RefreshTokenConcurrentRequestWindow = + TimeSpan.FromSeconds(5); + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-concurrent-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient(factory, deviceId); + + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); + + using var clientA = + CreateClient(factory, deviceId); + + using var clientB = + CreateClient(factory, deviceId); + + SetClientProfile(clientA, "BlazorWasm"); + SetClientProfile(clientB, "BlazorWasm"); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var responses = + await Task.WhenAll( + clientA.PostAsync("/auth/refresh", null), + clientB.PostAsync("/auth/refresh", null)); + + var winner = + responses.Single(x => + x.StatusCode == HttpStatusCode.NoContent); + + responses.Single(x => + x.StatusCode == HttpStatusCode.Unauthorized); + + var winningCredentials = + BuildHybridCredentialAfterRefresh( + originalCookies, + winner); + + // + // Losing concurrent request must NOT have + // revoked the chain. + // + using var continuation = + CreateClient(factory, deviceId); + + SetClientProfile( + continuation, + "BlazorWasm"); + + continuation.DefaultRequestHeaders.Add( + "Cookie", + winningCredentials); + + var result = + await continuation.PostAsync( + "/auth/refresh", + null); + + result.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_AfterConcurrentRotation_OriginalRefreshToken_ShouldRemainInvalid() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-race-replay-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); + + using var clientA = + CreateClient(deviceId); + + using var clientB = + CreateClient(deviceId); + + SetClientProfile(clientA, "BlazorWasm"); + SetClientProfile(clientB, "BlazorWasm"); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var responses = + await Task.WhenAll( + clientA.PostAsync("/auth/refresh", null), + clientB.PostAsync("/auth/refresh", null)); + + responses.Count(x => + x.StatusCode == HttpStatusCode.NoContent) + .Should() + .Be(1); + + // + // Try the original credential yet again. + // + using var replayClient = + CreateClient(deviceId); + + SetClientProfile( + replayClient, + "BlazorWasm"); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var replay = + await replayClient.PostAsync( + "/auth/refresh", + null); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_ConcurrentRotation_ShouldNotCreateAnotherChain() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-race-chain-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); + + var before = + await GetCurrentChainAsync(loginClient); + + using var clientA = + CreateClient(deviceId); + + using var clientB = + CreateClient(deviceId); + + SetClientProfile(clientA, "BlazorWasm"); + SetClientProfile(clientB, "BlazorWasm"); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var responses = + await Task.WhenAll( + clientA.PostAsync("/auth/refresh", null), + clientB.PostAsync("/auth/refresh", null)); + + var success = + responses.Single(x => + x.StatusCode == HttpStatusCode.NoContent); + + var winningCookies = + BuildHybridCredentialAfterRefresh( + originalCookies, + success); + + using var verificationClient = + CreateClient(deviceId); + + SetClientProfile( + verificationClient, + "BlazorWasm"); + + verificationClient.DefaultRequestHeaders.Add( + "Cookie", + winningCookies); + + var after = + await GetCurrentChainAsync( + verificationClient); + + after.ChainId.Should() + .Be(before.ChainId); + + after.IsRevoked.Should() + .BeFalse(); + } + + // ============================================================ + // HYBRID - REVOCATION / REPLAY SECURITY + // ============================================================ + + [Fact] + public async Task Refresh_Hybrid_AfterSessionRevoked_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"refresh-revoke-session-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + var before = await GetCurrentChainAsync(client); + + before.ActiveSessionId.Should().NotBeNull(); + + // + // Revoke the active session directly through the session store. + // This test is about refresh behaviour after revocation, + // not about testing the revoke endpoint again. + // + using (var scope = _factory.Services.CreateScope()) + { + var storeFactory = + scope.ServiceProvider.GetRequiredService(); + + var store = + storeFactory.Create(TenantKeys.Single); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + before.ActiveSessionId!.Value, + _factory.Clock.UtcNow, + ct)); + } + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = + await client.PostAsync("/auth/refresh", null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_AfterChainRevoked_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"refresh-revoke-chain-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + var before = + await GetCurrentChainAsync(client); + + using (var scope = _factory.Services.CreateScope()) + { + var storeFactory = + scope.ServiceProvider.GetRequiredService(); + + var store = + storeFactory.Create(TenantKeys.Single); + + await store.ExecuteAsync( + ct => store.RevokeChainCascadeAsync( + before.ChainId, + _factory.Clock.UtcNow, + ct)); + } + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = + await client.PostAsync("/auth/refresh", null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_AfterRootRevoked_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"refresh-revoke-root-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + // Sanity check: credential is valid before root revocation. + var before = await GetCurrentChainAsync(client); + + before.IsRevoked.Should().BeFalse(); + + using (var scope = _factory.Services.CreateScope()) + { + var storeFactory = + scope.ServiceProvider.GetRequiredService(); + + var store = + storeFactory.Create(TenantKeys.Single); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + user.UserKey, + _factory.Clock.UtcNow, + ct)); + } + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = + await client.PostAsync("/auth/refresh", null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_ReusingRotatedToken_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"refresh-replay-{Guid.NewGuid():N}"; + + using var loginClient = CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); + + // + // R0 -> R1 + // + var first = + await loginClient.PostAsync( + "/auth/refresh", + null); + + first.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + // + // Deliberately replay R0. + // + using var replayClient = + CreateClient(deviceId); + + SetClientProfile( + replayClient, + "BlazorWasm"); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var replay = + await replayClient.PostAsync( + "/auth/refresh", + null); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_ReplayAfterConcurrencyWindow_ShouldInvalidateReplacementCredential() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Token.RefreshTokenConcurrentRequestWindow = + TimeSpan.FromSeconds(2); + }); + + factory.Clock.Reset(); + + var user = + await factory.CreateLoginUserAsync(); + + var deviceId = + $"refresh-replay-family-{Guid.NewGuid():N}"; + + using var client = + CreateClient(factory, deviceId); + + var originalCookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + // + // R0 -> R1 + // + var rotation = + await client.PostAsync( + "/auth/refresh", + null); + + rotation.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var replacementCookies = + BuildHybridCredentialAfterRefresh( + originalCookies, + rotation); + + // + // Move OUTSIDE the concurrency tolerance window. + // + factory.Clock.Advance( + TimeSpan.FromSeconds(3)); + + // + // Replay R0. + // + using var attacker = + CreateClient(factory, deviceId); + + SetClientProfile( + attacker, + "BlazorWasm"); + + attacker.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var replay = + await attacker.PostAsync( + "/auth/refresh", + null); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Confirmed replay must invalidate the chain. + // Therefore R1 must also be dead. + // + using var legitimateClient = + CreateClient(factory, deviceId); + + SetClientProfile( + legitimateClient, + "BlazorWasm"); + + legitimateClient.DefaultRequestHeaders.Add( + "Cookie", + replacementCookies); + + var legitimateRefresh = + await legitimateClient.PostAsync( + "/auth/refresh", + null); + + legitimateRefresh.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_DuplicateWithinConcurrencyWindow_ShouldNotInvalidateReplacement() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromSeconds(2); + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + var deviceId = $"refresh-duplicate-window-{Guid.NewGuid():N}"; + + using var client = CreateClient(factory, deviceId); + + var originalCookies = await LoginAsync(client, user, "BlazorWasm"); + + // R0 -> R1 + var rotation = await client.PostAsync("/auth/refresh", null); + + rotation.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var replacementCookies = BuildHybridCredentialAfterRefresh(originalCookies, rotation); + + // Replay R0 immediately. + // Still inside duplicate tolerance window. + using var duplicate = CreateClient(factory, deviceId); + + SetClientProfile(duplicate, "BlazorWasm"); + + duplicate.DefaultRequestHeaders.Add("Cookie", originalCookies); + + var duplicateResponse = await duplicate.PostAsync("/auth/refresh", null); + + duplicateResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // Important: + // duplicate was rejected but must NOT have destroyed R1. + using var legitimate = CreateClient(factory, deviceId); + + SetClientProfile(legitimate, "BlazorWasm"); + + legitimate.DefaultRequestHeaders.Add("Cookie", replacementCookies); + + var continuation = await legitimate.PostAsync("/auth/refresh", null); + + continuation.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_ReplayOnOneChain_ShouldNotAffectOtherDeviceChain() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceA = + $"refresh-compromised-{Guid.NewGuid():N}"; + + var deviceB = + $"refresh-safe-{Guid.NewGuid():N}"; + + using var clientA = + CreateClient(deviceA); + + using var clientB = + CreateClient(deviceB); + + var originalCookiesA = + await LoginAsync( + clientA, + user, + "BlazorWasm"); + + var originalCookiesB = + await LoginAsync( + clientB, + user, + "BlazorWasm"); + + var chainA = + await GetCurrentChainAsync(clientA); + + var chainB = + await GetCurrentChainAsync(clientB); + + chainA.ChainId.Should() + .NotBe(chainB.ChainId); + + // + // Rotate device A: + // A:R0 -> A:R1 + // + var rotationA = + await clientA.PostAsync( + "/auth/refresh", + null); + + rotationA.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + // + // Replay A:R0. + // + using var attacker = + CreateClient(deviceA); + + SetClientProfile( + attacker, + "BlazorWasm"); + + attacker.DefaultRequestHeaders.Add( + "Cookie", + originalCookiesA); + + var replay = + await attacker.PostAsync( + "/auth/refresh", + null); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Device B belongs to another chain. + // Compromise of A must not kill B. + // + using var safeClient = + CreateClient(deviceB); + + SetClientProfile( + safeClient, + "BlazorWasm"); + + safeClient.DefaultRequestHeaders.Add( + "Cookie", + originalCookiesB); + + var safeRefresh = + await safeClient.PostAsync( + "/auth/refresh", + null); + + safeRefresh.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_JustBeforeRefreshTokenExpiry_ShouldSucceed() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"hybrid-expiry-before-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + _factory.Clock.Advance( + TimeSpan.FromDays(7) - TimeSpan.FromSeconds(1)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_AtRefreshTokenExpiry_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"hybrid-expiry-exact-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + _factory.Clock.Advance( + TimeSpan.FromDays(7)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_AfterRefreshTokenExpiry_ShouldReturnUnauthorized() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var deviceId = $"hybrid-expiry-after-{Guid.NewGuid():N}"; + + using var client = CreateClient(deviceId); + + var cookies = await LoginAsync( + client, + user, + "BlazorWasm"); + + _factory.Clock.Advance( + TimeSpan.FromDays(7) + TimeSpan.FromSeconds(1)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + + var response = await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WhenSessionExpired_ShouldReturnUnauthorized() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Session.Lifetime = + TimeSpan.FromMinutes(30); + + options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + }); + + factory.Clock.Reset(); + + var user = + await factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-session-expiry-{Guid.NewGuid():N}"; + + using var client = + CreateClient(factory, deviceId); + + var cookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + // + // Session expired, refresh token is still valid. + // + factory.Clock.Advance( + TimeSpan.FromMinutes(31)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add( + "Cookie", + cookies); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_WhenChainIdleTimeoutExceeded_ShouldReturnUnauthorized() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Session.Lifetime = + TimeSpan.FromDays(7); + + options.Session.IdleTimeout = + TimeSpan.FromMinutes(30); + + options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + }); + + factory.Clock.Reset(); + + var user = + await factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-idle-expiry-{Guid.NewGuid():N}"; + + using var client = + CreateClient(factory, deviceId); + + var cookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + factory.Clock.Advance( + TimeSpan.FromMinutes(31)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add( + "Cookie", + cookies); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_JustBeforeChainIdleTimeout_ShouldSucceed() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Session.Lifetime = + TimeSpan.FromDays(7); + + options.Session.IdleTimeout = + TimeSpan.FromMinutes(30); + + options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); + }); + + factory.Clock.Reset(); + + var user = + await factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-idle-before-{Guid.NewGuid():N}"; + + using var client = + CreateClient(factory, deviceId); + + var cookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + factory.Clock.Advance( + TimeSpan.FromMinutes(30) - + TimeSpan.FromSeconds(1)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add( + "Cookie", + cookies); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_AtChainIdleTimeout_ShouldReturnUnauthorized() + { + await using var factory = AuthServerFactory.Create(options => + { + options.Session.Lifetime = + TimeSpan.FromDays(7); + + options.Session.IdleTimeout = + TimeSpan.FromMinutes(30); + + options.Token.RefreshTokenLifetime = + TimeSpan.FromDays(7); }); - _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); - _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + factory.Clock.Reset(); + + var user = + await factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-idle-exact-{Guid.NewGuid():N}"; + + using var client = + CreateClient(factory, deviceId); + + var cookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + factory.Clock.Advance( + TimeSpan.FromMinutes(30)); + + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add( + "Cookie", + cookies); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + // ============================================================ + // HYBRID - RESPONSE / COOKIE CONTRACT + // ============================================================ + + [Fact] + public async Task Refresh_Hybrid_OnSuccess_ShouldRotateRefreshCookie() + { + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-cookie-rotate-{Guid.NewGuid():N}"; + + using var client = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + var originalRefresh = + GetCookie(originalCookies, "uar"); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var responseCookies = + BuildCookieHeader(response); + + var rotatedRefresh = + GetCookie(responseCookies, "uar"); + + rotatedRefresh.Should() + .NotBeNullOrWhiteSpace(); + + rotatedRefresh.Should() + .NotBe(originalRefresh); } [Fact] - public async Task Refresh_PureOpaque_Should_Touch_Session() + public async Task Refresh_Hybrid_OnSuccess_ShouldNotRotateSessionCookie() { - await LoginAsync("BlazorServer"); - var response = await RefreshAsync(); + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-cookie-session-{Guid.NewGuid():N}"; - response.StatusCode.Should().Be(HttpStatusCode.NoContent); - response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + using var client = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + var originalSession = + GetCookie(originalCookies, "uas"); + + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + // + // Hybrid refresh rotates the refresh credential. + // Session identity itself must remain stable. + // + var rotatedCredentials = + BuildHybridCredentialAfterRefresh( + originalCookies, + response); + + var sessionAfter = + GetCookie(rotatedCredentials, "uas"); + + sessionAfter.Should() + .Be(originalSession); } [Fact] - public async Task Refresh_PureOpaque_Invalid_Should_Return_Unauthorized() + public async Task Refresh_Hybrid_OnUnauthorized_ShouldNotIssueReplacementRefreshCookie() { - SetClientProfile("BlazorServer"); - var response = await RefreshAsync(); + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-cookie-failure-{Guid.NewGuid():N}"; + + using var client = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + // + // Remove refresh credential while preserving session. + // + var session = + GetCookie(originalCookies, "uas"); + + client.DefaultRequestHeaders.Remove("Cookie"); + + client.DefaultRequestHeaders.Add( + "Cookie", + session); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var response = + await client.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var issuedRefreshCookie = + response.Headers.TryGetValues( + "Set-Cookie", + out var setCookies) + && + setCookies.Any(x => + x.StartsWith( + "uar=", + StringComparison.OrdinalIgnoreCase)); + + issuedRefreshCookie.Should() + .BeFalse(); } [Fact] - public async Task Refresh_Hybrid_Should_Rotate_Tokens() + public async Task Refresh_Hybrid_OnReplayDetection_ShouldNotIssueReplacementRefreshCookie() { - await LoginAsync("BlazorWasm"); + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-cookie-replay-{Guid.NewGuid():N}"; + + using var client = + CreateClient(deviceId); + + var originalCookies = + await LoginAsync( + client, + user, + "BlazorWasm"); + + // + // R0 -> R1 + // + var rotation = + await client.PostAsync( + "/auth/refresh", + null); - var response = await RefreshAsync(); + rotation.StatusCode.Should() + .Be(HttpStatusCode.NoContent); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); + // + // Replay R0. + // + using var replayClient = + CreateClient(deviceId); - response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); - cookies.Should().NotBeEmpty(); + SetClientProfile( + replayClient, + "BlazorWasm"); + + replayClient.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var replay = + await replayClient.PostAsync( + "/auth/refresh", + null); + + replay.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var issuedRefreshCookie = + replay.Headers.TryGetValues( + "Set-Cookie", + out var setCookies) + && + setCookies.Any(x => + x.StartsWith( + "uar=", + StringComparison.OrdinalIgnoreCase)); + + issuedRefreshCookie.Should() + .BeFalse(); } [Fact] - public async Task Refresh_Hybrid_Should_Fail_On_Reuse() + public async Task Refresh_Hybrid_ConcurrentLoser_ShouldNotReceiveReplacementRefreshCookie() { - await LoginAsync("BlazorWasm"); + _factory.Clock.Reset(); + + var user = + await _factory.CreateLoginUserAsync(); + + var deviceId = + $"hybrid-cookie-race-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient(deviceId); - var first = await RefreshAsync(); - first.StatusCode.Should().Be(HttpStatusCode.NoContent); + var originalCookies = + await LoginAsync( + loginClient, + user, + "BlazorWasm"); - var second = await RefreshAsync(); + using var clientA = + CreateClient(deviceId); - second.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + using var clientB = + CreateClient(deviceId); + + SetClientProfile( + clientA, + "BlazorWasm"); + + SetClientProfile( + clientB, + "BlazorWasm"); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + originalCookies); + + var responses = + await Task.WhenAll( + clientA.PostAsync( + "/auth/refresh", + null), + clientB.PostAsync( + "/auth/refresh", + null)); + + var winner = + responses.Single(x => + x.StatusCode == + HttpStatusCode.NoContent); + + var loser = + responses.Single(x => + x.StatusCode == + HttpStatusCode.Unauthorized); + + winner.Headers.TryGetValues( + "Set-Cookie", + out var winnerCookies) + .Should() + .BeTrue(); + + winnerCookies.Should() + .Contain(x => + x.StartsWith( + "uar=", + StringComparison.OrdinalIgnoreCase)); + + var loserReceivedRefresh = + loser.Headers.TryGetValues( + "Set-Cookie", + out var loserCookies) + && + loserCookies.Any(x => + x.StartsWith( + "uar=", + StringComparison.OrdinalIgnoreCase)); + + loserReceivedRefresh.Should() + .BeFalse(); } + // ============================================================ + // PURE OPAQUE - RESPONSE / COOKIE CONTRACT + // ============================================================ + [Fact] - public async Task Refresh_PureOpaque_Should_Not_Touch_Immediately() + public async Task Refresh_PureOpaque_OnSuccess_ShouldNotIssueRefreshCookie() { - await LoginAsync("BlazorServer"); + _factory.Clock.Reset(); - var first = await RefreshAsync(); - first.StatusCode.Should().Be(HttpStatusCode.NoContent); + var user = + await _factory.CreateLoginUserAsync(); - var second = await RefreshAsync(); + await LoginAsync( + user, + "BlazorServer"); - second.StatusCode.Should().Be(HttpStatusCode.NoContent); + var response = + await RefreshAsync(); + + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + + var issuedRefreshCookie = + response.Headers.TryGetValues( + "Set-Cookie", + out var setCookies) + && + setCookies.Any(x => + x.StartsWith( + "uar=", + StringComparison.OrdinalIgnoreCase)); + + issuedRefreshCookie.Should() + .BeFalse(); } [Fact] - public async Task Refresh_Hybrid_Should_Fail_When_Session_Mismatch() + public async Task Refresh_PureOpaque_OnSuccess_ShouldPreserveSessionCookie() { - var factory = new AuthServerFactory(); + _factory.Clock.Reset(); - var client1 = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); + var user = + await _factory.CreateLoginUserAsync(); - var client2 = factory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); + var originalCookies = + await LoginAsync( + user, + "BlazorServer"); + + var originalSession = + GetCookie( + originalCookies, + "uas"); - var cookie1 = await LoginAsync(client1, "BlazorWasm", "device-1-1234567890123456"); - var cookie2 = await LoginAsync(client2, "BlazorWasm", "device-2-1234567890123456"); + var response = + await RefreshAsync(); - cookie1.Should().NotBeNullOrWhiteSpace(); - cookie2.Should().NotBeNullOrWhiteSpace(); - cookie1.Should().NotBe(cookie2); + response.StatusCode.Should() + .Be(HttpStatusCode.NoContent); - client2.DefaultRequestHeaders.Remove("Cookie"); - client2.DefaultRequestHeaders.Add("Cookie", cookie1); - var response = await client2.PostAsync("/auth/refresh", null); + // PureOpaque refresh is a validation/touch operation. + // It must not establish a different session identity. + var currentCookie = + _client.DefaultRequestHeaders + .GetValues("Cookie") + .Single(); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + GetCookie( + currentCookie, + "uas") + .Should() + .Be(originalSession); } [Fact] - public async Task Refresh_Hybrid_Should_Fail_Without_RefreshToken() + public async Task Refresh_Hybrid_CredentialFromTenantA_ShouldNotBeUsableInTenantB() { - await LoginAsync("BlazorWasm"); - var cookies = _client.DefaultRequestHeaders.GetValues("Cookie").First(); - var onlySession = string.Join("; ", cookies.Split("; ").Where(x => x.StartsWith("uas="))); + using var factory = + AuthServerFactory.Create(options => + { + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = "X-Tenant"; + }); - _client.DefaultRequestHeaders.Remove("Cookie"); - _client.DefaultRequestHeaders.Add("Cookie", onlySession); + factory.Clock.Reset(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var userA = + await factory.CreateLoginUserAsync( + tenant: tenantA); + + using var loginClient = + CreateClient( + factory, + $"tenant-a-device-{Guid.NewGuid():N}"); + + SetTenant( + loginClient, + "tenant-a"); + + var cookiesA = + await LoginAsync( + loginClient, + userA, + "BlazorWasm"); + + // + // Same credential is now presented under Tenant B. + // + using var tenantBClient = + CreateClient( + factory, + $"tenant-a-device-{Guid.NewGuid():N}"); + + SetClientProfile( + tenantBClient, + "BlazorWasm"); + + SetTenant( + tenantBClient, + "tenant-b"); + + tenantBClient.DefaultRequestHeaders.Add( + "Cookie", + cookiesA); + + var response = + await tenantBClient.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Refresh_Hybrid_CrossTenantAttempt_ShouldNotInvalidateSourceTenantCredential() + { + using var factory = + AuthServerFactory.Create(options => + { + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = "X-Tenant"; + }); + + factory.Clock.Reset(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var userA = + await factory.CreateLoginUserAsync( + tenant: tenantA); + + var deviceId = + $"tenant-isolation-{Guid.NewGuid():N}"; + + using var loginClient = + CreateClient( + factory, + deviceId); + + SetTenant( + loginClient, + "tenant-a"); + + var cookiesA = + await LoginAsync( + loginClient, + userA, + "BlazorWasm"); - var response = await _client.PostAsync("/auth/refresh", null); + // + // Attack / accidental cross-tenant presentation. + // + using var tenantBClient = + CreateClient( + factory, + deviceId); - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + SetClientProfile( + tenantBClient, + "BlazorWasm"); + + SetTenant( + tenantBClient, + "tenant-b"); + + tenantBClient.DefaultRequestHeaders.Add( + "Cookie", + cookiesA); + + var crossTenant = + await tenantBClient.PostAsync( + "/auth/refresh", + null); + + crossTenant.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Original credential must still work in its real tenant. + // + using var tenantAClient = + CreateClient( + factory, + deviceId); + + SetClientProfile( + tenantAClient, + "BlazorWasm"); + + SetTenant( + tenantAClient, + "tenant-a"); + + tenantAClient.DefaultRequestHeaders.Add( + "Cookie", + cookiesA); + + var legitimate = + await tenantAClient.PostAsync( + "/auth/refresh", + null); + + legitimate.StatusCode.Should() + .Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_DifferentTenants_ShouldRotateIndependently() + { + using var factory = + AuthServerFactory.Create(options => + { + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = "X-Tenant"; + }); + + factory.Clock.Reset(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var userA = + await factory.CreateLoginUserAsync( + tenant: tenantA); + + var userB = + await factory.CreateLoginUserAsync( + tenant: tenantB); + + using var clientA = + CreateClient( + factory, + $"tenant-a-{Guid.NewGuid():N}"); + + using var clientB = + CreateClient( + factory, + $"tenant-b-{Guid.NewGuid():N}"); + + SetTenant( + clientA, + "tenant-a"); + + SetTenant( + clientB, + "tenant-b"); + + await LoginAsync( + clientA, + userA, + "BlazorWasm"); + + await LoginAsync( + clientB, + userB, + "BlazorWasm"); + + var responses = + await Task.WhenAll( + clientA.PostAsync( + "/auth/refresh", + null), + clientB.PostAsync( + "/auth/refresh", + null)); + + responses.Should() + .OnlyContain(x => + x.StatusCode == + HttpStatusCode.NoContent); + } + + [Fact] + public async Task Refresh_Hybrid_SessionFromTenantAAndRefreshFromTenantB_ShouldBeRejected() + { + using var factory = + AuthServerFactory.Create(options => + { + options.MultiTenant.Enabled = true; + options.MultiTenant.EnableHeader = true; + options.MultiTenant.HeaderName = "X-Tenant"; + }); + + factory.Clock.Reset(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var deviceId = + $"tenant-mixed-{Guid.NewGuid():N}"; + + var userA = + await factory.CreateLoginUserAsync( + tenant: tenantA); + + var userB = + await factory.CreateLoginUserAsync( + tenant: tenantB); + + using var clientA = + CreateClient(factory, deviceId); + + using var clientB = + CreateClient(factory, deviceId); + + SetTenant(clientA, "tenant-a"); + SetTenant(clientB, "tenant-b"); + + var cookiesA = + await LoginAsync( + clientA, + userA, + "BlazorWasm"); + + var cookiesB = + await LoginAsync( + clientB, + userB, + "BlazorWasm"); + + var sessionA = + GetCookie(cookiesA, "uas"); + + var refreshB = + GetCookie(cookiesB, "uar"); + + var mixedCredential = + $"{sessionA}; {refreshB}"; + + using var attacker = + CreateClient(factory, deviceId); + + SetClientProfile( + attacker, + "BlazorWasm"); + + SetTenant( + attacker, + "tenant-a"); + + attacker.DefaultRequestHeaders.Add( + "Cookie", + mixedCredential); + + var response = + await attacker.PostAsync( + "/auth/refresh", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); } - private async Task LoginAsync(string profile) + + private async Task LoginAsync(IntegrationTestUser user, string profile = "BlazorServer") { SetClientProfile(profile); - var response = await _client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var response = await _client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should() + .Be(HttpStatusCode.Found); - var cookies = response.Headers.GetValues("Set-Cookie") - .Select(x => x.Split(';')[0]); + var cookieHeader = + BuildCookieHeader(response); - var cookieHeader = string.Join("; ", cookies); + cookieHeader.Should() + .NotBeNullOrWhiteSpace(); _client.DefaultRequestHeaders.Remove("Cookie"); - _client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + + _client.DefaultRequestHeaders.Add( + "Cookie", + cookieHeader); + + return cookieHeader; } - private async Task LoginAsync(HttpClient client, string profile, string udid = "test-device-1234567890123456") + private static async Task LoginAsync(HttpClient client, IntegrationTestUser user, string profile) { - client.DefaultRequestHeaders.Remove("Origin"); - client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); - - client.DefaultRequestHeaders.Remove("X-UDID"); - client.DefaultRequestHeaders.Add("X-UDID", udid); + client.DefaultRequestHeaders.Remove( + "X-UAuth-ClientProfile"); - SetClientProfile(client, profile); + client.DefaultRequestHeaders.Add( + "X-UAuth-ClientProfile", + profile); - var response = await client.PostAsJsonAsync("/auth/login", new - { - identifier = "admin", - secret = "admin" - }); + var response = + await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = user.Identifier, + secret = user.Secret + }); response.StatusCode.Should().Be(HttpStatusCode.Found); var cookieHeader = BuildCookieHeader(response); + cookieHeader.Should().NotBeNullOrWhiteSpace(); client.DefaultRequestHeaders.Remove("Cookie"); client.DefaultRequestHeaders.Add("Cookie", cookieHeader); @@ -173,13 +2547,25 @@ private async Task LoginAsync(HttpClient client, string profile, string return cookieHeader; } + private Task RefreshAsync() + { + return _client.PostAsync("/auth/refresh", null); + } + + private ISessionStore GetSessionStore() + { + var factory = _factory.Services.GetRequiredService(); + + return factory.Create(TenantKeys.Single); + } + private void SetClientProfile(string profile) { _client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); _client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); } - private void SetClientProfile(HttpClient client, string profile) + private static void SetClientProfile(HttpClient client, string profile) { client.DefaultRequestHeaders.Remove("X-UAuth-ClientProfile"); client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", profile); @@ -187,14 +2573,153 @@ private void SetClientProfile(HttpClient client, string profile) private static string BuildCookieHeader(HttpResponseMessage response) { - var cookies = response.Headers.GetValues("Set-Cookie") - .Select(x => x.Split(';')[0]); + if (!response.Headers.TryGetValues("Set-Cookie", out var values)) + return string.Empty; - return string.Join("; ", cookies); + return string.Join( + "; ", + values.Select(x => x.Split(';')[0])); } - private Task RefreshAsync() + private static async Task GetCurrentChainAsync(HttpClient client) { - return _client.PostAsync("/auth/refresh", null); + var response = await GetChainsAsync(client); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync>(); + result.Should().NotBeNull(); + + return result!.Items.Single(x => x.IsCurrentDevice); + } + + private static async Task GetChainsAsync(HttpClient client) + { + return await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + } + + private HttpClient CreateClient(string deviceId) + { + var client = + _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static HttpClient CreateClient(AuthServerFactory factory, string deviceId) + { + var client = + factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static string GetCookie(string cookieHeader, string name) + { + return cookieHeader + .Split( + "; ", + StringSplitOptions.RemoveEmptyEntries) + .Single(x => + x.StartsWith( + $"{name}=", + StringComparison.Ordinal)); + } + + private void ApplyResponseCookies(HttpResponseMessage response) + { + var cookieHeader = BuildCookieHeader(response); + cookieHeader.Should().NotBeNullOrWhiteSpace(); + + _client.DefaultRequestHeaders.Remove("Cookie"); + _client.DefaultRequestHeaders.Add("Cookie", cookieHeader); + } + + private async Task RevokeSessionDirectlyAsync(AuthSessionId sessionId) + { + using var scope = _factory.Services.CreateScope(); + + var factory = scope.ServiceProvider.GetRequiredService(); + + var store = factory.Create(TenantKeys.Single); + + await store.ExecuteAsync(ct => store.RevokeSessionAsync(sessionId, _factory.Clock.UtcNow, ct)); + } + + private async Task RevokeChainDirectlyAsync(SessionChainId chainId) + { + using var scope = _factory.Services.CreateScope(); + + var factory = scope.ServiceProvider.GetRequiredService(); + + var store = factory.Create(TenantKeys.Single); + + await store.ExecuteAsync(ct => store.RevokeChainCascadeAsync(chainId, _factory.Clock.UtcNow,ct)); + } + + private async Task RevokeRootDirectlyAsync(UserKey userKey) + { + using var scope = _factory.Services.CreateScope(); + + var factory = scope.ServiceProvider.GetRequiredService(); + + var store = factory.Create(TenantKeys.Single); + + await store.ExecuteAsync(ct => store.RevokeRootCascadeAsync(userKey, _factory.Clock.UtcNow, ct)); + } + + private static string BuildHybridCredentialAfterRefresh(string originalCookies, HttpResponseMessage refreshResponse) + { + var session = + GetCookie(originalCookies, "uas"); + + var refreshed = + BuildCookieHeader(refreshResponse); + + var refresh = + GetCookie(refreshed, "uar"); + + return $"{session}; {refresh}"; + } + + private static void SetTenant(HttpClient client, string tenant) + { + client.DefaultRequestHeaders.Remove( + "X-Tenant"); + + client.DefaultRequestHeaders.Add( + "X-Tenant", + tenant); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs new file mode 100644 index 00000000..6dd6dee3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionAdminTests.cs @@ -0,0 +1,1706 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class SessionAdminTests : IClassFixture +{ + private readonly AuthServerFactory _factory; + + public SessionAdminTests(AuthServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ListChainsAdmin_ShouldReturnTargetUsersChains() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + var unrelated = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.ListChainsAdmin + ]); + + using var adminClient = CreateClient( + $"session-admin-list-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"session-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"session-target-2-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"session-unrelated-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret); + + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret); + + await LoginAsync( + unrelatedClient, + unrelated.Identifier, + unrelated.Secret); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + page!.Items.Should().HaveCount(2); + + // + // Admin is querying somebody else. + // No chain should therefore be reported as admin's current device. + // + page.Items.Should() + .OnlyContain(x => !x.IsCurrentDevice); + + page.Items.Should() + .OnlyContain(x => x.ActiveSessionId != null); + } + + [Fact] + public async Task GetChainAdmin_ShouldReturnTargetUsersOwnedChain() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.GetChainAdmin + ]); + + using var adminClient = CreateClient( + $"session-admin-detail-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"session-target-detail-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var targetChains = await GetChainsAsync(targetClient); + + targetChains.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await targetChains.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var targetChain = + page!.Items.Single(x => x.IsCurrentDevice); + + var response = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}", + null); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var detail = await response.Content + .ReadFromJsonAsync(); + + detail.Should().NotBeNull(); + + detail!.ChainId.Should() + .Be(targetChain.ChainId); + + detail.ActiveSessionId.Should() + .Be(targetChain.ActiveSessionId); + + detail.Sessions.Should() + .NotBeEmpty(); + } + + [Fact] + public async Task GetChainAdmin_ShouldRejectChainOwnedByDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + + var targetA = await _factory.CreateLoginUserAsync(); + var targetB = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.GetChainAdmin + ]); + + using var adminClient = CreateClient( + $"session-admin-cross-user-{Guid.NewGuid():N}"); + + using var targetAClient = CreateClient( + $"session-target-a-{Guid.NewGuid():N}"); + + using var targetBClient = CreateClient( + $"session-target-b-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetACookie = GetSessionCookie( + await LoginAsync( + targetAClient, + targetA.Identifier, + targetA.Secret)); + + var targetBCookie = GetSessionCookie( + await LoginAsync( + targetBClient, + targetB.Identifier, + targetB.Secret)); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + adminCookie); + + targetAClient.DefaultRequestHeaders.Add( + "Cookie", + targetACookie); + + targetBClient.DefaultRequestHeaders.Add( + "Cookie", + targetBCookie); + + // + // Obtain B's chain. + // + var targetBChains = + await GetChainsAsync(targetBClient); + + var targetBPage = await targetBChains.Content + .ReadFromJsonAsync>(); + + targetBPage.Should().NotBeNull(); + + var targetBChain = + targetBPage!.Items.Single(x => x.IsCurrentDevice); + + // + // URL says A, ChainId belongs to B. + // + var response = await adminClient.PostAsync( + $"/auth/admin/users/{targetA.UserKey.Value}/sessions/chains/{targetBChain.ChainId.Value}", + null); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound); + + // + // Critical invariant: + // query failure must not mutate either user. + // + var targetAVerification = + await GetChainsAsync(targetAClient); + + var targetBVerification = + await GetChainsAsync(targetBClient); + + targetAVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + targetBVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SessionAdminQueryEndpoints_ShouldRequireAdminPermission() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"session-admin-no-permission-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"session-admin-no-permission-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add( + "Cookie", + actorCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var targetChains = + await GetChainsAsync(targetClient); + + var targetPage = await targetChains.Content + .ReadFromJsonAsync>(); + + targetPage.Should().NotBeNull(); + + var targetChain = + targetPage!.Items.Single(x => x.IsCurrentDevice); + + var list = await actorClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains", + new PageRequest()); + + list.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.Unauthorized); + + var detail = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}", + null); + + detail.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.Unauthorized); + + // + // Denied query must have no side effects. + // + var verification = + await GetChainsAsync(targetClient); + + verification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SessionAdminQueryEndpoints_ShouldRejectUnauthenticatedRequests() + { + var target = await _factory.CreateLoginUserAsync(); + + using var anonymous = CreateClient( + $"session-admin-anonymous-{Guid.NewGuid():N}"); + + var randomChain = SessionChainId.New(); + + var list = await anonymous.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains", + new PageRequest()); + + list.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var detail = await anonymous.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{randomChain.Value}", + null); + + detail.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RevokeSessionAdmin_ShouldRevokeTargetUsersSession() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeSessionAdmin + ]); + + using var adminClient = CreateClient( + $"session-revoke-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"session-revoke-target-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var chainsResponse = await GetChainsAsync(targetClient); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var chain = page!.Items.Single(x => x.IsCurrentDevice); + + chain.ActiveSessionId.Should().NotBeNull(); + + var sessionId = chain.ActiveSessionId!.Value; + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/{sessionId.Value}/revoke", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Revoked session can no longer authenticate. + // + var targetVerification = await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Admin's own authority must remain intact. + // + var adminVerification = await GetChainsAsync(adminClient); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeSessionAdmin_ShouldRejectSessionOwnedByDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var targetA = await _factory.CreateLoginUserAsync(); + var targetB = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeSessionAdmin + ]); + + using var adminClient = CreateClient( + $"session-revoke-cross-admin-{Guid.NewGuid():N}"); + + using var targetAClient = CreateClient( + $"session-revoke-cross-a-{Guid.NewGuid():N}"); + + using var targetBClient = CreateClient( + $"session-revoke-cross-b-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetACookie = GetSessionCookie( + await LoginAsync( + targetAClient, + targetA.Identifier, + targetA.Secret)); + + var targetBCookie = GetSessionCookie( + await LoginAsync( + targetBClient, + targetB.Identifier, + targetB.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetAClient.DefaultRequestHeaders.Add("Cookie", targetACookie); + targetBClient.DefaultRequestHeaders.Add("Cookie", targetBCookie); + + // + // Obtain B's SessionId. + // + var bChainsResponse = await GetChainsAsync(targetBClient); + + bChainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var bPage = await bChainsResponse.Content + .ReadFromJsonAsync>(); + + bPage.Should().NotBeNull(); + + var bChain = bPage!.Items.Single(x => x.IsCurrentDevice); + + bChain.ActiveSessionId.Should().NotBeNull(); + + var bSessionId = bChain.ActiveSessionId!.Value; + + // + // URL claims target A, but SessionId belongs to B. + // + var attack = await adminClient.PostAsync( + $"/auth/admin/users/{targetA.UserKey.Value}/sessions/{bSessionId.Value}/revoke", + null); + + attack.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound); + + // + // Absolutely no mutation should have occurred. + // + var aVerification = await GetChainsAsync(targetAClient); + var bVerification = await GetChainsAsync(targetBClient); + var adminVerification = await GetChainsAsync(adminClient); + + aVerification.StatusCode.Should().Be(HttpStatusCode.OK); + bVerification.StatusCode.Should().Be(HttpStatusCode.OK); + adminVerification.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeSessionAdmin_ShouldRequirePermission() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"session-revoke-no-permission-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"session-revoke-no-permission-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var chainsResponse = await GetChainsAsync(targetClient); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var sessionId = page!.Items + .Single(x => x.IsCurrentDevice) + .ActiveSessionId; + + sessionId.Should().NotBeNull(); + + var revoke = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/{sessionId!.Value.Value}/revoke", + null); + + revoke.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.Unauthorized); + + // + // Denied mutation must really be mutation-free. + // + var targetVerification = await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeSessionAdmin_ShouldNotAffectOtherSessionOfSameUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeSessionAdmin + ]); + + using var adminClient = CreateClient( + $"session-single-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"session-single-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"session-single-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", targetCookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", targetCookie2); + + // + // Find client1's current chain/session. + // + var chainsResponse = await GetChainsAsync(targetClient1); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var chain1 = page!.Items.Single(x => x.IsCurrentDevice); + + chain1.ActiveSessionId.Should().NotBeNull(); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/{chain1.ActiveSessionId!.Value.Value}/revoke", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Exact targeted session dies. + // + var verification1 = await GetChainsAsync(targetClient1); + + verification1.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Different chain/session of same user survives. + // + var verification2 = await GetChainsAsync(targetClient2); + + verification2.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Admin survives. + // + var adminVerification = await GetChainsAsync(adminClient); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainAdmin_ShouldRevokeTargetChain() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeChainAdmin + ]); + + using var adminClient = CreateClient( + $"revoke-chain-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"revoke-chain-target-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var chainsResponse = await GetChainsAsync(targetClient); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var targetChain = + page!.Items.Single(x => x.IsCurrentDevice); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}/revoke", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Target chain is no longer usable. + // + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Admin remains authenticated. + // + var adminVerification = + await GetChainsAsync(adminClient); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainAdmin_ShouldRejectChainOwnedByDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var targetA = await _factory.CreateLoginUserAsync(); + var targetB = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeChainAdmin + ]); + + using var adminClient = CreateClient( + $"revoke-chain-cross-admin-{Guid.NewGuid():N}"); + + using var targetAClient = CreateClient( + $"revoke-chain-cross-a-{Guid.NewGuid():N}"); + + using var targetBClient = CreateClient( + $"revoke-chain-cross-b-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var cookieA = GetSessionCookie( + await LoginAsync( + targetAClient, + targetA.Identifier, + targetA.Secret)); + + var cookieB = GetSessionCookie( + await LoginAsync( + targetBClient, + targetB.Identifier, + targetB.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetAClient.DefaultRequestHeaders.Add("Cookie", cookieA); + targetBClient.DefaultRequestHeaders.Add("Cookie", cookieB); + + var bChainsResponse = + await GetChainsAsync(targetBClient); + + bChainsResponse.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var bPage = await bChainsResponse.Content + .ReadFromJsonAsync>(); + + bPage.Should().NotBeNull(); + + var bChain = + bPage!.Items.Single(x => x.IsCurrentDevice); + + // + // URL target = A + // Chain owner = B + // + var attack = await adminClient.PostAsync( + $"/auth/admin/users/{targetA.UserKey.Value}/sessions/chains/{bChain.ChainId.Value}/revoke", + null); + + attack.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound); + + // + // Failed ownership check must cause zero mutation. + // + var aVerification = + await GetChainsAsync(targetAClient); + + var bVerification = + await GetChainsAsync(targetBClient); + + var adminVerification = + await GetChainsAsync(adminClient); + + aVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + bVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainAdmin_ShouldNotAffectOtherChainOfSameUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [ + UAuthActions.Sessions.RevokeChainAdmin + ]); + + using var adminClient = CreateClient( + $"revoke-chain-isolation-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"revoke-chain-device-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"revoke-chain-device-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var cookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + + // + // From device 1's perspective identify its own chain. + // + var chainsResponse = + await GetChainsAsync(targetClient1); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + page!.Items.Should().HaveCountGreaterThanOrEqualTo(2); + + var chain1 = + page.Items.Single(x => x.IsCurrentDevice); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{chain1.ChainId.Value}/revoke", + null); + + revoke.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Revoked chain dies. + // + var device1Verification = + await GetChainsAsync(targetClient1); + + device1Verification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Sibling chain survives. + // + var device2Verification = + await GetChainsAsync(targetClient2); + + device2Verification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Actor remains usable. + // + var adminVerification = + await GetChainsAsync(adminClient); + + adminVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainAdmin_ShouldRequirePermissionWithoutMutation() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"revoke-chain-denied-actor-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"revoke-chain-denied-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var chainsResponse = + await GetChainsAsync(targetClient); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var targetChain = + page!.Items.Single(x => x.IsCurrentDevice); + + var response = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/chains/{targetChain.ChainId.Value}/revoke", + null); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.Unauthorized); + + // + // Authorization denial must occur before mutation. + // + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeAllChainsAdmin_ShouldRevokeAllTargetUsersChains() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Sessions.RevokeAllChainsAdmin]); + + using var adminClient = CreateClient( + $"revoke-all-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"revoke-all-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"revoke-all-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var cookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + + // + // Sanity check: both target chains are usable before revoke. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Every currently active chain belonging to target must die. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Actor remains usable. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeAllChainsAdmin_ShouldNotAffectDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + var unrelated = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Sessions.RevokeAllChainsAdmin]); + + using var adminClient = CreateClient( + $"revoke-all-isolation-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"revoke-all-isolation-target-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"revoke-all-isolation-unrelated-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + var unrelatedCookie = GetSessionCookie( + await LoginAsync( + unrelatedClient, + unrelated.Identifier, + unrelated.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + unrelatedClient.DefaultRequestHeaders.Add("Cookie", unrelatedCookie); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Target dies. + // + (await GetChainsAsync(targetClient)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Completely unrelated user must survive. + // + (await GetChainsAsync(unrelatedClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Admin must survive too. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeAllChainsAdmin_ShouldRequirePermissionWithoutMutation() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"revoke-all-denied-actor-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"revoke-all-denied-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"revoke-all-denied-target-2-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var cookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", cookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", cookie2); + + var response = await actorClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", + null); + + response.StatusCode.Should().BeOneOf( + HttpStatusCode.Forbidden, + HttpStatusCode.Unauthorized); + + // + // Authorization denial must happen before mutation. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeAllChainsAdmin_ShouldRejectUnauthenticatedRequestWithoutMutation() + { + _factory.Clock.Reset(); + + var target = await _factory.CreateLoginUserAsync(); + + using var anonymousClient = CreateClient( + $"revoke-all-anonymous-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"revoke-all-anonymous-target-{Guid.NewGuid():N}"); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var response = await anonymousClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-all", + null); + + response.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Anonymous request must produce zero mutation. + // + var verification = + await GetChainsAsync(targetClient); + + verification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ShouldInvalidateAllExistingTargetSessions() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Sessions.RevokeRootAdmin]); + + using var adminClient = CreateClient( + $"root-admin-{Guid.NewGuid():N}"); + + using var targetClient1 = CreateClient( + $"root-target-1-{Guid.NewGuid():N}"); + + using var targetClient2 = CreateClient( + $"root-target-2-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie1 = GetSessionCookie( + await LoginAsync( + targetClient1, + target.Identifier, + target.Secret)); + + var targetCookie2 = GetSessionCookie( + await LoginAsync( + targetClient2, + target.Identifier, + target.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient1.DefaultRequestHeaders.Add("Cookie", targetCookie1); + targetClient2.DefaultRequestHeaders.Add("Cookie", targetCookie2); + + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Root cascade must invalidate every existing target session. + // + (await GetChainsAsync(targetClient1)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient2)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + // + // Actor remains valid. + // + (await GetChainsAsync(adminClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ShouldNotAffectDifferentUser() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + var unrelated = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Sessions.RevokeRootAdmin]); + + using var adminClient = CreateClient( + $"root-isolation-admin-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"root-isolation-target-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"root-isolation-unrelated-{Guid.NewGuid():N}"); + + var adminCookie = GetSessionCookie( + await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + target.Identifier, + target.Secret)); + + var unrelatedCookie = GetSessionCookie( + await LoginAsync( + unrelatedClient, + unrelated.Identifier, + unrelated.Secret)); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + unrelatedClient.DefaultRequestHeaders.Add("Cookie", unrelatedCookie); + + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(targetClient)) + .StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + (await GetChainsAsync(unrelatedClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + + (await GetChainsAsync(adminClient)) + .StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ShouldRequirePermissionWithoutMutation() + { + _factory.Clock.Reset(); + + var actor = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + using var actorClient = CreateClient( + $"root-denied-actor-{Guid.NewGuid():N}"); + + using var targetClient = CreateClient( + $"root-denied-target-{Guid.NewGuid():N}"); + + var actorCookie = GetSessionCookie( + await LoginAsync( + actorClient, + actor.Identifier, + actor.Secret)); + + var targetCookie = GetSessionCookie(await LoginAsync(targetClient, target.Identifier, target.Secret)); + + actorClient.DefaultRequestHeaders.Add("Cookie", actorCookie); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var response = await actorClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + + response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized); + + (await GetChainsAsync(targetClient)).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ShouldRejectUnauthenticatedRequestWithoutMutation() + { + _factory.Clock.Reset(); + + var target = await _factory.CreateLoginUserAsync(); + + using var anonymousClient = CreateClient($"root-anonymous-{Guid.NewGuid():N}"); + using var targetClient = CreateClient($"root-anonymous-target-{Guid.NewGuid():N}"); + + var targetCookie = GetSessionCookie(await LoginAsync(targetClient, target.Identifier, target.Secret)); + targetClient.DefaultRequestHeaders.Add("Cookie", targetCookie); + + var response = await anonymousClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + (await GetChainsAsync(targetClient)).StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ShouldAllowSubsequentLoginWithNewAuthenticationRoot() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Sessions.RevokeRootAdmin]); + + var adminDevice = $"root-recreate-admin-{Guid.NewGuid():N}"; + + var oldTargetDevice = $"root-recreate-target-old-{Guid.NewGuid():N}"; + + using var adminClient = CreateClient(adminDevice); + using var oldTargetClient = CreateClient(oldTargetDevice); + + var adminLogin = await LoginAsync(adminClient, admin.Identifier, admin.Secret); + + adminLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var adminCookie = GetSessionCookie(adminLogin); + + adminClient.DefaultRequestHeaders.Add("Cookie", adminCookie); + + var oldLogin = await LoginAsync(oldTargetClient, target.Identifier, target.Secret); + + oldLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var oldCookie = GetSessionCookie(oldLogin); + + oldTargetClient.DefaultRequestHeaders.Add("Cookie", oldCookie); + + var beforeRevoke = await GetChainsAsync(oldTargetClient); + + beforeRevoke.StatusCode.Should().Be(HttpStatusCode.OK); + + var revoke = await adminClient.PostAsync($"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + var oldSessionAfterRevoke = await GetChainsAsync(oldTargetClient); + oldSessionAfterRevoke.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + using var newTargetClient = CreateClient($"root-recreate-target-new-{Guid.NewGuid():N}"); + + var newLogin = await LoginAsync(newTargetClient, target.Identifier, target.Secret); + newLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var newCookie = GetSessionCookie(newLogin); + + newCookie.Should().NotBeNullOrWhiteSpace(); + newCookie.Should().NotBe(oldCookie); + + newTargetClient.DefaultRequestHeaders.Add("Cookie", newCookie); + + var newSessionVerification = await GetChainsAsync(newTargetClient); + newSessionVerification.StatusCode.Should().Be(HttpStatusCode.OK); + + var oldSessionVerification = await GetChainsAsync(oldTargetClient); + oldSessionVerification.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + var adminVerification = await GetChainsAsync(adminClient); + adminVerification.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeRootAdmin_ConcurrentSubsequentLogins_ShouldCreateSingleActiveRoot() + { + _factory.Clock.Reset(); + + var admin = await _factory.CreateLoginUserAsync(); + var target = await _factory.CreateLoginUserAsync(); + + await _factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Sessions.RevokeRootAdmin]); + + using var adminClient = + CreateClient($"root-concurrent-admin-{Guid.NewGuid():N}"); + + using var initialTargetClient = + CreateClient($"root-concurrent-initial-{Guid.NewGuid():N}"); + + // + // Establish initial authentication root. + // + var adminLogin = await LoginAsync( + adminClient, + admin.Identifier, + admin.Secret); + + adminLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + adminClient.DefaultRequestHeaders.Add( + "Cookie", + GetSessionCookie(adminLogin)); + + var initialLogin = await LoginAsync( + initialTargetClient, + target.Identifier, + target.Secret); + + initialLogin.StatusCode.Should().Be(HttpStatusCode.Found); + + var initialCookie = GetSessionCookie(initialLogin); + + initialTargetClient.DefaultRequestHeaders.Add( + "Cookie", + initialCookie); + + // + // Revoke the target's current authentication root. + // + var revoke = await adminClient.PostAsync( + $"/auth/admin/users/{target.UserKey.Value}/sessions/revoke-root", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + var oldSessionVerification = + await GetChainsAsync(initialTargetClient); + + oldSessionVerification.StatusCode + .Should().Be(HttpStatusCode.Unauthorized); + + // + // Two devices authenticate concurrently after there is + // no active root. + // + using var clientA = + CreateClient($"root-concurrent-a-{Guid.NewGuid():N}"); + + using var clientB = + CreateClient($"root-concurrent-b-{Guid.NewGuid():N}"); + + var loginTaskA = LoginAsync( + clientA, + target.Identifier, + target.Secret); + + var loginTaskB = LoginAsync( + clientB, + target.Identifier, + target.Secret); + + var responses = await Task.WhenAll( + loginTaskA, + loginTaskB); + + responses.Should().OnlyContain( + x => x.StatusCode == HttpStatusCode.Found); + + var cookieA = GetSessionCookie(responses[0]); + var cookieB = GetSessionCookie(responses[1]); + + cookieA.Should().NotBeNullOrWhiteSpace(); + cookieB.Should().NotBeNullOrWhiteSpace(); + + clientA.DefaultRequestHeaders.Add( + "Cookie", + cookieA); + + clientB.DefaultRequestHeaders.Add( + "Cookie", + cookieB); + + // + // Both sessions must belong to the surviving active + // authentication generation and remain usable. + // + var verificationA = await GetChainsAsync(clientA); + var verificationB = await GetChainsAsync(clientB); + + verificationA.StatusCode.Should().Be(HttpStatusCode.OK); + verificationB.StatusCode.Should().Be(HttpStatusCode.OK); + + var chainsA = await verificationA.Content + .ReadFromJsonAsync>(); + + chainsA.Should().NotBeNull(); + + chainsA!.Items + .Count(x => !x.IsRevoked) + .Should().Be(2); + + chainsA.Items + .Count(x => x.IsCurrentDevice) + .Should().Be(1); + + var chainsB = await verificationB.Content + .ReadFromJsonAsync>(); + + chainsB.Should().NotBeNull(); + + chainsB!.Items + .Count(x => !x.IsRevoked) + .Should().Be(2); + + chainsB.Items + .Count(x => x.IsCurrentDevice) + .Should().Be(1); + + // + // Historical authentication material must remain invalid. + // + var oldVerification = + await GetChainsAsync(initialTargetClient); + + oldVerification.StatusCode + .Should().Be(HttpStatusCode.Unauthorized); + + // + // Admin session must be unaffected. + // + var adminVerification = + await GetChainsAsync(adminClient); + + adminVerification.StatusCode + .Should().Be(HttpStatusCode.OK); + } + + // --------------------------------------------------------- + // Existing LogoutAdminTests helper can be moved to a shared + // integration-test helper later if desired. + // --------------------------------------------------------- + + private HttpClient CreateClient(string deviceId) + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static Task LoginAsync(HttpClient client, string identifier, string secret) + { + return client.PostAsJsonAsync( + "/auth/login", + new + { + identifier, + secret + }); + } + + private static string GetSessionCookie(HttpResponseMessage response) + { + response.StatusCode.Should() + .Be(HttpStatusCode.Found); + + response.Headers.TryGetValues( + "Set-Cookie", + out var values).Should().BeTrue(); + + var cookie = values! + .First(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + + return cookie.Split(';', 2)[0]; + } + + private static Task GetChainsAsync(HttpClient client) + { + return client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs new file mode 100644 index 00000000..050ddcc1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SessionTests.cs @@ -0,0 +1,714 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class SessionTests : IClassFixture +{ + private readonly AuthServerFactory _factory; + + public SessionTests(AuthServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ListChainsSelf_ShouldReturnOnlyAuthenticatedUsersChains() + { + _factory.Clock.Reset(); + + var user1 = await _factory.CreateLoginUserAsync(); + var user2 = await _factory.CreateLoginUserAsync(); + + using var user1Device1 = CreateClient( + $"session-user1-device1-{Guid.NewGuid():N}"); + + using var user1Device2 = CreateClient( + $"session-user1-device2-{Guid.NewGuid():N}"); + + using var user2Device = CreateClient( + $"session-user2-device-{Guid.NewGuid():N}"); + + var user1Cookie1 = GetSessionCookie( + await LoginAsync(user1Device1, user1.Identifier, user1.Secret)); + + await LoginAsync( + user1Device2, + user1.Identifier, + user1.Secret); + + await LoginAsync( + user2Device, + user2.Identifier, + user2.Secret); + + user1Device1.DefaultRequestHeaders.Add( + "Cookie", + user1Cookie1); + + var response = await GetChainsAsync(user1Device1); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + page!.Items.Should().HaveCount(2); + page.Items.Count(x => x.IsCurrentDevice).Should().Be(1); + } + + [Fact] + public async Task GetChainSelf_ShouldReturnRequestedOwnedChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client = CreateClient( + $"session-detail-{Guid.NewGuid():N}"); + + var cookie = GetSessionCookie( + await LoginAsync( + client, + user.Identifier, + user.Secret)); + + client.DefaultRequestHeaders.Add( + "Cookie", + cookie); + + var chainsResponse = await GetChainsAsync(client); + + chainsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await chainsResponse.Content + .ReadFromJsonAsync>(); + + page.Should().NotBeNull(); + + var chain = page!.Items.Single(x => x.IsCurrentDevice); + + var detailResponse = await client.PostAsync( + $"/auth/me/sessions/chains/{chain.ChainId.Value}", + null); + + detailResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var detail = await detailResponse.Content + .ReadFromJsonAsync(); + + detail.Should().NotBeNull(); + detail!.ChainId.Should().Be(chain.ChainId); + detail.ActiveSessionId.Should().NotBeNull(); + detail.Sessions.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetChainSelf_ShouldNotAllowReadingAnotherUsersChain() + { + _factory.Clock.Reset(); + + var attacker = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + + using var attackerClient = CreateClient( + $"session-attacker-{Guid.NewGuid():N}"); + + using var victimClient = CreateClient( + $"session-victim-{Guid.NewGuid():N}"); + + var attackerCookie = GetSessionCookie( + await LoginAsync( + attackerClient, + attacker.Identifier, + attacker.Secret)); + + var victimCookie = GetSessionCookie( + await LoginAsync( + victimClient, + victim.Identifier, + victim.Secret)); + + attackerClient.DefaultRequestHeaders.Add( + "Cookie", + attackerCookie); + + victimClient.DefaultRequestHeaders.Add( + "Cookie", + victimCookie); + + var victimChainsResponse = + await GetChainsAsync(victimClient); + + var victimChains = await victimChainsResponse.Content + .ReadFromJsonAsync>(); + + victimChains.Should().NotBeNull(); + + var victimChain = + victimChains!.Items.Single(x => x.IsCurrentDevice); + + var attack = await attackerClient.PostAsync( + $"/auth/me/sessions/chains/{victimChain.ChainId.Value}", + null); + + attack.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Unauthorized); + + // Most important assertion: + // victim must still be authenticated. + var victimVerification = + await GetChainsAsync(victimClient); + + victimVerification.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainSelf_ShouldRevokeOwnedTargetChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + var currentDevice = + $"session-current-{Guid.NewGuid():N}"; + + var targetDevice = + $"session-target-{Guid.NewGuid():N}"; + + using var currentClient = CreateClient(currentDevice); + using var targetClient = CreateClient(targetDevice); + + var currentCookie = GetSessionCookie( + await LoginAsync( + currentClient, + user.Identifier, + user.Secret)); + + var targetCookie = GetSessionCookie( + await LoginAsync( + targetClient, + user.Identifier, + user.Secret)); + + currentClient.DefaultRequestHeaders.Add( + "Cookie", + currentCookie); + + targetClient.DefaultRequestHeaders.Add( + "Cookie", + targetCookie); + + var targetChainsResponse = + await GetChainsAsync(targetClient); + + var targetChains = await targetChainsResponse.Content + .ReadFromJsonAsync>(); + + targetChains.Should().NotBeNull(); + + var targetChain = + targetChains!.Items.Single(x => x.IsCurrentDevice); + + var revoke = await currentClient.PostAsync( + $"/auth/me/sessions/chains/{targetChain.ChainId.Value}/revoke", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Target session must immediately stop working. + // + var targetVerification = + await GetChainsAsync(targetClient); + + targetVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Actor's own chain must remain usable. + // + var actorVerification = + await GetChainsAsync(currentClient); + + actorVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeChainSelf_ShouldNotAllowRevokingAnotherUsersChain() + { + _factory.Clock.Reset(); + + var attacker = await _factory.CreateLoginUserAsync(); + var victim = await _factory.CreateLoginUserAsync(); + + using var attackerClient = CreateClient( + $"session-revoke-attacker-{Guid.NewGuid():N}"); + + using var victimClient = CreateClient( + $"session-revoke-victim-{Guid.NewGuid():N}"); + + var attackerCookie = GetSessionCookie( + await LoginAsync( + attackerClient, + attacker.Identifier, + attacker.Secret)); + + var victimCookie = GetSessionCookie( + await LoginAsync( + victimClient, + victim.Identifier, + victim.Secret)); + + attackerClient.DefaultRequestHeaders.Add( + "Cookie", + attackerCookie); + + victimClient.DefaultRequestHeaders.Add( + "Cookie", + victimCookie); + + var victimChainsResponse = + await GetChainsAsync(victimClient); + + var victimChains = await victimChainsResponse.Content + .ReadFromJsonAsync>(); + + victimChains.Should().NotBeNull(); + + var victimChain = + victimChains!.Items.Single(x => x.IsCurrentDevice); + + var attack = await attackerClient.PostAsync( + $"/auth/me/sessions/chains/{victimChain.ChainId.Value}/revoke", + null); + + attack.StatusCode.Should().BeOneOf( + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Unauthorized); + + // + // Security invariant: + // victim's session must NOT have been revoked. + // + var victimVerification = + await GetChainsAsync(victimClient); + + victimVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Attacker must also remain unaffected. + // + var attackerVerification = + await GetChainsAsync(attackerClient); + + attackerVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task SessionSelfEndpoints_ShouldRejectUnauthenticatedRequests() + { + using var client = CreateClient( + $"session-anonymous-{Guid.NewGuid():N}"); + + var list = await client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest()); + + list.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var randomChain = SessionChainId.New(); + + var detail = await client.PostAsync( + $"/auth/me/sessions/chains/{randomChain.Value}", + null); + + detail.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var revoke = await client.PostAsync( + $"/auth/me/sessions/chains/{randomChain.Value}/revoke", + null); + + revoke.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var revokeOthers = await client.PostAsync( + "/auth/me/sessions/revoke-others", + null); + + revokeOthers.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + var revokeAll = await client.PostAsync( + "/auth/me/sessions/revoke-all", + null); + + revokeAll.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RevokeOthersSelf_ShouldRevokeAllOtherChainsButKeepCurrentChain() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var currentClient = CreateClient( + $"revoke-others-current-{Guid.NewGuid():N}"); + + using var otherClient1 = CreateClient( + $"revoke-others-other1-{Guid.NewGuid():N}"); + + using var otherClient2 = CreateClient( + $"revoke-others-other2-{Guid.NewGuid():N}"); + + var currentCookie = GetSessionCookie( + await LoginAsync( + currentClient, + user.Identifier, + user.Secret)); + + var otherCookie1 = GetSessionCookie( + await LoginAsync( + otherClient1, + user.Identifier, + user.Secret)); + + var otherCookie2 = GetSessionCookie( + await LoginAsync( + otherClient2, + user.Identifier, + user.Secret)); + + currentClient.DefaultRequestHeaders.Add( + "Cookie", + currentCookie); + + otherClient1.DefaultRequestHeaders.Add( + "Cookie", + otherCookie1); + + otherClient2.DefaultRequestHeaders.Add( + "Cookie", + otherCookie2); + + var revoke = await currentClient.PostAsync( + "/auth/me/sessions/revoke-others", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Current chain survives. + // + var currentVerification = + await GetChainsAsync(currentClient); + + currentVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Every other chain becomes unusable. + // + var otherVerification1 = + await GetChainsAsync(otherClient1); + + var otherVerification2 = + await GetChainsAsync(otherClient2); + + otherVerification1.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + otherVerification2.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RevokeOthersSelf_ShouldNotAffectAnotherUsersChains() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var unrelatedUser = await _factory.CreateLoginUserAsync(); + + using var currentClient = CreateClient( + $"revoke-others-owner-current-{Guid.NewGuid():N}"); + + using var otherClient = CreateClient( + $"revoke-others-owner-other-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"revoke-others-unrelated-{Guid.NewGuid():N}"); + + var currentCookie = GetSessionCookie( + await LoginAsync( + currentClient, + user.Identifier, + user.Secret)); + + var otherCookie = GetSessionCookie( + await LoginAsync( + otherClient, + user.Identifier, + user.Secret)); + + var unrelatedCookie = GetSessionCookie( + await LoginAsync( + unrelatedClient, + unrelatedUser.Identifier, + unrelatedUser.Secret)); + + currentClient.DefaultRequestHeaders.Add( + "Cookie", + currentCookie); + + otherClient.DefaultRequestHeaders.Add( + "Cookie", + otherCookie); + + unrelatedClient.DefaultRequestHeaders.Add( + "Cookie", + unrelatedCookie); + + var revoke = await currentClient.PostAsync( + "/auth/me/sessions/revoke-others", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Same user's other chain is revoked. + // + var otherVerification = + await GetChainsAsync(otherClient); + + otherVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Completely unrelated user must not be affected. + // + var unrelatedVerification = + await GetChainsAsync(unrelatedClient); + + unrelatedVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + + // + // Actor survives. + // + var currentVerification = + await GetChainsAsync(currentClient); + + currentVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RevokeAllSelf_ShouldRevokeCurrentAndAllOtherChains() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + + using var client1 = CreateClient( + $"revoke-all-1-{Guid.NewGuid():N}"); + + using var client2 = CreateClient( + $"revoke-all-2-{Guid.NewGuid():N}"); + + using var client3 = CreateClient( + $"revoke-all-3-{Guid.NewGuid():N}"); + + var cookie1 = GetSessionCookie( + await LoginAsync( + client1, + user.Identifier, + user.Secret)); + + var cookie2 = GetSessionCookie( + await LoginAsync( + client2, + user.Identifier, + user.Secret)); + + var cookie3 = GetSessionCookie( + await LoginAsync( + client3, + user.Identifier, + user.Secret)); + + client1.DefaultRequestHeaders.Add("Cookie", cookie1); + client2.DefaultRequestHeaders.Add("Cookie", cookie2); + client3.DefaultRequestHeaders.Add("Cookie", cookie3); + + // + // Client1 revokes every chain, including itself. + // + var revoke = await client1.PostAsync( + "/auth/me/sessions/revoke-all", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + var verification1 = await GetChainsAsync(client1); + var verification2 = await GetChainsAsync(client2); + var verification3 = await GetChainsAsync(client3); + + verification1.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + verification2.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + verification3.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task RevokeAllSelf_ShouldNotAffectAnotherUser() + { + _factory.Clock.Reset(); + + var user = await _factory.CreateLoginUserAsync(); + var unrelatedUser = await _factory.CreateLoginUserAsync(); + + using var userClient = CreateClient( + $"revoke-all-owner-{Guid.NewGuid():N}"); + + using var unrelatedClient = CreateClient( + $"revoke-all-unrelated-{Guid.NewGuid():N}"); + + var userCookie = GetSessionCookie( + await LoginAsync( + userClient, + user.Identifier, + user.Secret)); + + var unrelatedCookie = GetSessionCookie( + await LoginAsync( + unrelatedClient, + unrelatedUser.Identifier, + unrelatedUser.Secret)); + + userClient.DefaultRequestHeaders.Add( + "Cookie", + userCookie); + + unrelatedClient.DefaultRequestHeaders.Add( + "Cookie", + unrelatedCookie); + + var revoke = await userClient.PostAsync( + "/auth/me/sessions/revoke-all", + null); + + revoke.StatusCode.Should().Be(HttpStatusCode.OK); + + // + // Requesting user's session is gone. + // + var userVerification = + await GetChainsAsync(userClient); + + userVerification.StatusCode.Should() + .Be(HttpStatusCode.Unauthorized); + + // + // Different user's authority tree must remain untouched. + // + var unrelatedVerification = + await GetChainsAsync(unrelatedClient); + + unrelatedVerification.StatusCode.Should() + .Be(HttpStatusCode.OK); + } + + + private HttpClient CreateClient(string deviceId) + { + var client = _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + deviceId); + + return client; + } + + private static async Task LoginAsync( + HttpClient client, + string identifier, + string secret) + { + return await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier, + secret + }); + } + + private static string GetSessionCookie( + HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.Found); + + response.Headers.TryGetValues( + "Set-Cookie", + out var values).Should().BeTrue(); + + var cookie = values! + .First(x => + x.StartsWith( + "uas=", + StringComparison.OrdinalIgnoreCase)); + + var cookiePair = cookie + .Split(';', 2)[0]; + + cookiePair.Should().NotBeNullOrWhiteSpace(); + + return cookiePair; + } + + private static Task GetChainsAsync( + HttpClient client) + { + return client.PostAsJsonAsync( + "/auth/me/sessions/chains", + new PageRequest + { + PageNumber = 1, + PageSize = 50 + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs new file mode 100644 index 00000000..e418ec43 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs @@ -0,0 +1,614 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class UserLifecycleTests : IClassFixture +{ + private readonly AuthServerFactory _factory; + + public UserLifecycleTests(AuthServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task CreateUser_ShouldCreateUsableUser() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = $"user-{Guid.NewGuid():N}"; + var secret = $"Test-{Guid.NewGuid():N}!"; + + var response = await client.PostAsJsonAsync( + "/auth/users/create", + new CreateUserRequest + { + UserName = username, + Password = secret, + DisplayName = "Integration User" + }); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var result = + await response.Content.ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Succeeded.Should().BeTrue(); + result.UserKey.Should().NotBe(default); + + // + // The aggregate must be usable, not merely persisted. + // + var login = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = username, + secret + }); + + login.StatusCode.Should() + .Be(HttpStatusCode.Found); + } + + [Fact] + public async Task CreateUser_ShouldCreateLifecycle() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = $"lifecycle-{Guid.NewGuid():N}"; + + var result = await CreateUserAsync( + client, + username); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var lifecycle = await store.GetAsync( + new UserLifecycleKey( + TenantKeys.Single, + userKey)); + + lifecycle.Should().NotBeNull(); + + lifecycle!.UserKey.Should() + .Be(userKey); + + lifecycle.Tenant.Should() + .Be(TenantKeys.Single); + + lifecycle.IsDeleted.Should() + .BeFalse(); + + lifecycle.Status.Should() + .Be(UserStatus.Active); + + lifecycle.CreatedAt.Should() + .Be(_factory.Clock.UtcNow); + } + + [Fact] + public async Task CreateUser_ShouldCreateDefaultProfile() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = $"profile-{Guid.NewGuid():N}"; + var displayName = "Lifecycle Integration User"; + + var result = await CreateUserAsync( + client, + username, + displayName: displayName); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var profile = await store.GetAsync( + new UserProfileKey( + TenantKeys.Single, + userKey, + ProfileKey.Default)); + + profile.Should().NotBeNull(); + + profile!.UserKey.Should() + .Be(userKey); + + profile.ProfileKey.Should() + .Be(ProfileKey.Default); + + profile.DisplayName.Should() + .Be(displayName); + + profile.IsDeleted.Should() + .BeFalse(); + } + + [Fact] + public async Task CreateUser_WithUsername_ShouldCreatePrimaryUsername() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"identifier-{Guid.NewGuid():N}"; + + var result = await CreateUserAsync( + client, + username); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var identifiers = + await store.GetByUserAsync( + userKey); + + var identifier = identifiers + .Single(x => + x.Type == UserIdentifierType.Username); + + identifier.Value.Should() + .Be(username); + + identifier.UserKey.Should() + .Be(userKey); + + identifier.IsPrimary.Should() + .BeTrue(); + + identifier.IsDeleted.Should() + .BeFalse(); + } + + [Fact] + public async Task CreateUser_WithVerifiedEmail_ShouldPersistEmailAsVerified() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"email-user-{Guid.NewGuid():N}"; + + var email = + $"{Guid.NewGuid():N}@example.com"; + + var result = await CreateUserAsync( + client, + username, + email: email, + emailVerified: true); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var identifiers = + await store.GetByUserAsync( + userKey); + + var identifier = identifiers + .Single(x => + x.Type == UserIdentifierType.Email); + + identifier.Value.Should() + .Be(email); + + identifier.IsPrimary.Should() + .BeTrue(); + + identifier.IsVerified.Should() + .BeTrue(); + + identifier.VerifiedAt.Should() + .Be(_factory.Clock.UtcNow); + } + + [Fact] + public async Task CreateUser_WithUnverifiedEmail_ShouldPersistEmailAsUnverified() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"unverified-{Guid.NewGuid():N}"; + + var email = + $"{Guid.NewGuid():N}@example.com"; + + var result = await CreateUserAsync( + client, + username, + email: email, + emailVerified: false); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var identifiers = + await store.GetByUserAsync( + userKey); + + var identifier = identifiers + .Single(x => + x.Type == UserIdentifierType.Email); + + identifier.IsVerified.Should() + .BeFalse(); + + identifier.VerifiedAt.Should() + .BeNull(); + } + + [Fact] + public async Task CreateUser_WithUsernameAndEmail_ShouldCreateBothIdentifiers() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"multi-{Guid.NewGuid():N}"; + + var email = + $"{Guid.NewGuid():N}@example.com"; + + var result = await CreateUserAsync( + client, + username, + email: email); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var factory = + scope.ServiceProvider + .GetRequiredService(); + + var store = + factory.Create(TenantKeys.Single); + + var identifiers = + await store.GetByUserAsync( + userKey); + + identifiers.Should() + .ContainSingle(x => + x.Type == UserIdentifierType.Username && + x.Value == username); + + identifiers.Should() + .ContainSingle(x => + x.Type == UserIdentifierType.Email && + x.Value == email); + } + + [Fact] + public async Task CreateUser_ShouldCreateCredentialAndAllowLogin() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"credential-{Guid.NewGuid():N}"; + + var secret = + $"Test-{Guid.NewGuid():N}!"; + + await CreateUserAsync( + client, + username, + secret: secret); + + // + // Do not inspect password hashes here. + // + // The externally observable invariant is that the + // lifecycle integration produced a credential that + // the authentication pipeline can consume. + // + var login = await client.PostAsJsonAsync( + "/auth/login", + new + { + identifier = username, + secret + }); + + login.StatusCode.Should() + .Be(HttpStatusCode.Found); + + login.Headers.Contains("Set-Cookie") + .Should() + .BeTrue(); + } + + //[Fact] + //public async Task CreateUser_WithDuplicateUsername_ShouldNotCreateSecondUser() + //{ + // _factory.Clock.Reset(); + + // using var client = CreateClient(); + + // var username = + // $"duplicate-{Guid.NewGuid():N}"; + + // var first = + // await CreateUserResponseAsync( + // client, + // username); + + // first.StatusCode.Should() + // .Be(HttpStatusCode.OK); + + // var second = + // await CreateUserResponseAsync( + // client, + // username); + + // var secondResult = await second.Content.ReadFromJsonAsync(); + + // secondResult.Should().NotBeNull(); + + // secondResult!.Succeeded.Should() + // .BeFalse(); + + // secondResult.FailureReason.Should() + // .NotBeNullOrWhiteSpace(); + + + // second.IsSuccessStatusCode.Should().BeFalse(); + + // // Verify the important invariant: + // // only one active identifier owns this username. + // using var scope = _factory.Services.CreateScope(); + + // var factory = + // scope.ServiceProvider + // .GetRequiredService(); + + // var store = + // factory.Create(TenantKeys.Single); + + // var normalized = + // scope.ServiceProvider + // .GetRequiredService() + // .Normalize( + // UserIdentifierType.Username, + // username); + + // var identifier = + // await store.GetAsync( + // UserIdentifierType.Username, + // normalized.Normalized); + + // identifier.Should().NotBeNull(); + // identifier!.IsDeleted.Should().BeFalse(); + //} + + [Fact] + public async Task CreateUser_ResultUserKey_ShouldMatchPersistedAggregate() + { + _factory.Clock.Reset(); + + using var client = CreateClient(); + + var username = + $"key-{Guid.NewGuid():N}"; + + var result = + await CreateUserAsync( + client, + username); + + var userKey = GetUserKey(result); + + using var scope = + _factory.Services.CreateScope(); + + var lifecycleFactory = + scope.ServiceProvider + .GetRequiredService(); + + var profileFactory = + scope.ServiceProvider + .GetRequiredService(); + + var identifierFactory = + scope.ServiceProvider + .GetRequiredService(); + + var lifecycle = + await lifecycleFactory + .Create(TenantKeys.Single) + .GetAsync( + new UserLifecycleKey( + TenantKeys.Single, + userKey)); + + var profile = + await profileFactory + .Create(TenantKeys.Single) + .GetAsync( + new UserProfileKey( + TenantKeys.Single, + userKey, + ProfileKey.Default)); + + var identifiers = + await identifierFactory + .Create(TenantKeys.Single) + .GetByUserAsync( + userKey); + + lifecycle.Should().NotBeNull(); + profile.Should().NotBeNull(); + identifiers.Should().NotBeEmpty(); + + lifecycle!.UserKey.Should() + .Be(userKey); + + profile!.UserKey.Should() + .Be(userKey); + + identifiers.Should() + .OnlyContain(x => + x.UserKey == userKey); + } + + + // ------------------------------------------------------- + // Helpers + // ------------------------------------------------------- + + private HttpClient CreateClient() + { + var client = + _factory.CreateClient( + new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + client.DefaultRequestHeaders.Add( + "Origin", + "https://localhost:6130"); + + client.DefaultRequestHeaders.Add( + "X-UDID", + $"user-lifecycle-{Guid.NewGuid():N}"); + + return client; + } + + private async Task CreateUserAsync( + HttpClient client, + string username, + string? secret = null, + string? displayName = null, + string? email = null, + bool emailVerified = false) + { + var response = + await CreateUserResponseAsync( + client, + username, + secret, + displayName, + email, + emailVerified); + + response.StatusCode.Should() + .Be(HttpStatusCode.OK); + + var result = + await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.Succeeded.Should().BeTrue(); + + return result; + } + + private static Task + CreateUserResponseAsync( + HttpClient client, + string username, + string? secret = null, + string? displayName = null, + string? email = null, + bool emailVerified = false) + { + secret ??= + $"Test-{Guid.NewGuid():N}!"; + + return client.PostAsJsonAsync( + "/auth/users/create", + new CreateUserRequest + { + UserName = username, + Email = email, + EmailVerified = emailVerified, + DisplayName = displayName ?? username, + Password = secret + }); + } + + + private static UserKey GetUserKey(UserCreateResult result) + { + result.UserKey.Should().NotBeNullOrWhiteSpace(); + + return UserKey.FromString(result.UserKey!); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs index e3fc6db3..0f05a231 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs @@ -231,6 +231,7 @@ public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges() var updated = original.RegisterFailure( Now, threshold: 3, + failureWindow: TimeSpan.FromMinutes(5), lockoutDuration: TimeSpan.FromMinutes(15)); await store.UpdateAsync( @@ -266,6 +267,7 @@ public async Task UpdateAsync_WhenExpectedVersionIsStale_ThrowsConflict() var updated = original.RegisterFailure( Now, threshold: 3, + failureWindow: TimeSpan.FromMinutes(5), lockoutDuration: TimeSpan.FromMinutes(15)); var act = () => store.UpdateAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs index 18f513ac..4ce16d91 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -14,9 +14,9 @@ public sealed class RefreshTokenValidatorTests { private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; - private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory) + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory, ITokenHasher hasher) { - return new UAuthRefreshTokenValidator(factory, CreateHasher()); + return new UAuthRefreshTokenValidator(factory, hasher); } private static ITokenHasher CreateHasher() @@ -28,7 +28,8 @@ private static ITokenHasher CreateHasher() public async Task Invalid_When_Token_Not_Found() { var factory = new InMemoryRefreshTokenStoreFactory(); - var validator = CreateValidator(factory); + var hasher = CreateHasher(); + var validator = CreateValidator(factory, hasher); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -40,21 +41,21 @@ public async Task Invalid_When_Token_Not_Found() }); Assert.False(result.IsValid); - Assert.False(result.IsReuseDetected); + Assert.Equal(RefreshTokenValidationState.NotFound, result.State); } [Fact] - public async Task Reuse_Detected_When_Token_is_Revoked() + public async Task Invalid_When_Token_Is_Revoked_Without_Replacement() { var factory = new InMemoryRefreshTokenStoreFactory(); var store = factory.Create(TenantKey.Single); var hasher = CreateHasher(); - var validator = CreateValidator(factory); + var validator = CreateValidator(factory, hasher); var now = DateTimeOffset.UtcNow; - var rawToken = "refresh-token-1"; + const string rawToken = "refresh-token-1"; var hash = hasher.Hash(rawToken); var token = RefreshToken.Create( @@ -67,9 +68,8 @@ public async Task Reuse_Detected_When_Token_is_Revoked() now.AddMinutes(-5), now.AddMinutes(5)); - var revoked = token.Revoke(now); - - await store.StoreAsync(revoked); + await store.StoreAsync( + token.Revoke(now)); var result = await validator.ValidateAsync( new RefreshTokenValidationContext @@ -77,26 +77,102 @@ public async Task Reuse_Detected_When_Token_is_Revoked() Tenant = TenantKey.Single, RefreshToken = rawToken, Now = now, - Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + Device = DeviceContext.Create( + DeviceId.Create(ValidDeviceId), + null, + null, + null, + null, + null) }); Assert.False(result.IsValid); - Assert.True(result.IsReuseDetected); + Assert.Equal( + RefreshTokenValidationState.Invalid, + result.State); } [Fact] - public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + public async Task Consumed_When_Token_Was_Replaced_By_Rotation() { var factory = new InMemoryRefreshTokenStoreFactory(); var store = factory.Create(TenantKey.Single); - var validator = CreateValidator(factory); + var hasher = CreateHasher(); + var validator = CreateValidator(factory, hasher); var now = DateTimeOffset.UtcNow; + const string rawToken = "refresh-token-1"; + var hash = hasher.Hash(rawToken); + var token = RefreshToken.Create( TokenId.New(), - "hash-2", + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + SessionChainId.New(), + now.AddMinutes(-5), + now.AddMinutes(5)); + + await store.StoreAsync( + token.Revoke( + now, + "replacement-refresh-token-hash")); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = rawToken, + Now = now, + Device = DeviceContext.Create( + DeviceId.Create(ValidDeviceId), + null, + null, + null, + null, + null) + }); + + Assert.False(result.IsValid); + Assert.Equal( + RefreshTokenValidationState.Consumed, + result.State); + + Assert.Equal( + "replacement-refresh-token-hash", + result.ReplacedByTokenHash); + } + + [Fact] + public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + { + var factory = + new InMemoryRefreshTokenStoreFactory(); + + var store = + factory.Create(TenantKey.Single); + + var hasher = + CreateHasher(); + + var validator = + CreateValidator(factory, hasher); + + var now = + DateTimeOffset.UtcNow; + + const string rawToken = + "refresh-token-2"; + + var tokenHash = + hasher.Hash(rawToken); + + var token = RefreshToken.Create( + TokenId.New(), + tokenHash, TenantKey.Single, UserKey.FromString("user-1"), TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), @@ -110,29 +186,51 @@ public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() new RefreshTokenValidationContext { Tenant = TenantKey.Single, - RefreshToken = "hash-2", - ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), + RefreshToken = rawToken, + ExpectedSessionId = + TestIds.Session( + "session-2-cccccccccccccccccccccc"), Now = now, - Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + Device = DeviceContext.Create( + DeviceId.Create(ValidDeviceId), + null, + null, + null, + null, + null), }); Assert.False(result.IsValid); - Assert.False(result.IsReuseDetected); + + Assert.Equal( + RefreshTokenValidationState.Invalid, + result.State); } [Fact] public async Task Invalid_When_Token_Is_Expired() { - var factory = new InMemoryRefreshTokenStoreFactory(); - var store = factory.Create(TenantKey.Single); + var factory = + new InMemoryRefreshTokenStoreFactory(); - var validator = CreateValidator(factory); + var store = + factory.Create(TenantKey.Single); - var now = DateTimeOffset.UtcNow; + var hasher = CreateHasher(); + var validator = CreateValidator(factory, hasher); + + var now = + DateTimeOffset.UtcNow; + + const string rawToken = + "expired-refresh-token"; + + var tokenHash = + hasher.Hash(rawToken); var token = RefreshToken.Create( TokenId.New(), - "expired-hash", + tokenHash, TenantKey.Single, UserKey.FromString("user-1"), TestIds.Session("session-expired"), @@ -146,13 +244,22 @@ public async Task Invalid_When_Token_Is_Expired() new RefreshTokenValidationContext { Tenant = TenantKey.Single, - RefreshToken = "expired-hash", + RefreshToken = rawToken, Now = now, - Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + Device = DeviceContext.Create( + DeviceId.Create(ValidDeviceId), + null, + null, + null, + null, + null), }); Assert.False(result.IsValid); - Assert.False(result.IsReuseDetected); + + Assert.Equal( + RefreshTokenValidationState.Expired, + result.State); } [Fact] @@ -161,7 +268,8 @@ public async Task Valid_When_Token_Is_Active() var factory = new InMemoryRefreshTokenStoreFactory(); var store = factory.Create(TenantKey.Single); - var validator = CreateValidator(factory); + var hasher = CreateHasher(); + var validator = CreateValidator(factory, hasher); var now = DateTimeOffset.UtcNow; @@ -190,7 +298,7 @@ public async Task Valid_When_Token_Is_Active() }); Assert.True(result.IsValid); - Assert.False(result.IsReuseDetected); + Assert.Equal(RefreshTokenValidationState.Valid, result.State); } [Fact] @@ -199,7 +307,8 @@ public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() var factory = new InMemoryRefreshTokenStoreFactory(); var store = factory.Create(TenantKey.Single); - var validator = CreateValidator(factory); + var hasher = CreateHasher(); + var validator = CreateValidator(factory, hasher); var now = DateTimeOffset.UtcNow; @@ -228,6 +337,6 @@ public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() }); Assert.False(result.IsValid); - Assert.True(result.IsReuseDetected); + Assert.Equal(RefreshTokenValidationState.Consumed, result.State); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs index 79de0b8f..6239983e 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs @@ -62,6 +62,7 @@ public async Task Update_With_RegisterFailure_Should_Increment_Version() var updated = existing!.RegisterFailure( DateTimeOffset.UtcNow, threshold: 3, + failureWindow: TimeSpan.FromMinutes(5), lockoutDuration: TimeSpan.FromMinutes(5)); await store.UpdateAsync(updated, expectedVersion: 0); @@ -89,7 +90,7 @@ public async Task Update_With_Wrong_Version_Should_Throw() var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); await store.AddAsync(state); - var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5)); + var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); await Assert.ThrowsAsync(() => store.UpdateAsync(updated, expectedVersion: 999)); } @@ -103,7 +104,7 @@ public async Task RegisterSuccess_Should_Clear_Failures() var userKey = UserKey.FromGuid(Guid.NewGuid()); var state = AuthenticationSecurityState.CreateAccount(tenant, userKey) - .RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5)); + .RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); await using (var db1 = CreateDb(connection)) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs index 504bcf49..fee1f22c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; using Microsoft.Data.Sqlite; using System.Security.Claims; @@ -842,15 +843,21 @@ public async Task SaveRoot_Should_Increment_Version() var tenant = TenantKeys.Single; var userKey = UserKey.FromGuid(Guid.NewGuid()); + SessionRootId rootId; + await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); var root = UAuthSessionRoot.Create( tenant, userKey, DateTimeOffset.UtcNow); + rootId = root.RootId; + await store.ExecuteAsync(async ct => { await store.CreateRootAsync(root, ct); @@ -859,23 +866,39 @@ await store.ExecuteAsync(async ct => await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); + var store = new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); await store.ExecuteAsync(async ct => { - var existing = await store.GetRootByUserAsync(userKey, ct); - var updated = existing!.Revoke(DateTimeOffset.UtcNow); + var existing = + await store.GetActiveRootByUserAsync(userKey, ct); - await store.SaveRootAsync(updated, expectedVersion: 0, ct); + existing.Should().NotBeNull(); + + var updated = + existing!.Revoke(DateTimeOffset.UtcNow); + + await store.SaveRootAsync( + updated, + expectedVersion: existing.Version, + ct); }); } await using (var db = CreateDb(connection)) { - var store = new EfCoreSessionStore(db, new TenantExecutionContext(tenant)); - var result = await store.GetRootByUserAsync(userKey); + var store = new EfCoreSessionStore( + db, + new TenantExecutionContext(tenant)); - Assert.Equal(1, result!.Version); + var result = + await store.GetRootByIdAsync(rootId); + + result.Should().NotBeNull(); + result!.Version.Should().Be(1); + result.IsRevoked.Should().BeTrue(); } } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs index a37eb1d7..bb66a0e9 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -6,13 +6,17 @@ using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.InMemory; using CodeBeam.UltimateAuth.Sample.Seed.Extensions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; using CodeBeam.UltimateAuth.Users.Reference; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -70,6 +74,85 @@ public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) return Services.GetRequiredService().CreateAsync(httpContext, AuthFlowType.Login); } + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + var flowService = scope.ServiceProvider.GetRequiredService(); + + return await flowService.LoginAsync(flow, execution, request, ct); + } + + public async Task CreateLoginUserAsync(string? identifier = null, string? secret = null, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + + var services = scope.ServiceProvider; + + var lifecycleFactory = + services.GetRequiredService(); + + var identifierFactory = + services.GetRequiredService(); + + var credentialFactory = + services.GetRequiredService(); + + var normalizer = + services.GetRequiredService(); + + var hasher = + services.GetRequiredService(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.New(); + + identifier ??= $"unit-{Guid.NewGuid():N}"; + secret ??= $"Test-{Guid.NewGuid():N}!"; + + var now = Clock.UtcNow; + + var lifecycleStore = lifecycleFactory.Create(tenant); + var identifierStore = identifierFactory.Create(tenant); + var credentialStore = credentialFactory.Create(tenant); + + await lifecycleStore.AddAsync( + UserLifecycle.Create( + tenant, + userKey, + now), + ct); + + var normalized = normalizer.Normalize( + UserIdentifierType.Username, + identifier).Normalized; + + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + identifier, + normalized, + now, + isPrimary: true, + verifiedAt: now), + ct); + + await credentialStore.AddAsync( + PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + hasher.Hash(secret), + CredentialSecurityState.Active(), + new CredentialMetadata(), + now), + ct); + + return new TestLoginUser(userKey, identifier, secret); + } + public IUserApplicationService GetUserApplicationService() { var scope = Services.CreateScope(); @@ -95,4 +178,6 @@ public async Task LoginAsync(AuthFlowContext flow) Secret = "user" }); } + + internal sealed record TestLoginUser(UserKey UserKey, string Identifier, string Secret); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs index 8aeb4392..c14609dc 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -432,4 +432,220 @@ public async Task Internal_Login_Should_Respect_LoginExecutionOptions() result.IsSuccess.Should().BeTrue(); } + + [Fact] + public async Task Concurrent_First_Logins_FromDifferentDevices_ShouldCreateSingleRootAndDifferentChains() + { + var runtime = new TestAuthRuntime(); + var user = await runtime.CreateLoginUserAsync(); + var storeFactory = runtime.Services.GetRequiredService(); + var store = storeFactory.Create(TenantKeys.Single); + var rootBefore = await store.GetActiveRootByUserAsync(user.UserKey); + + rootBefore.Should().BeNull(); + + var flowA = await runtime.CreateLoginFlowAsync(); + var flowB = await runtime.CreateLoginFlowAsync(); + + var executionA = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.BlazorWasm, + Device = TestDevice.Default() + }; + + var executionB = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.BlazorWasm, + Device = TestDevice.Alternative() + }; + + var requestA = new LoginRequest + { + Identifier = user.Identifier, + Secret = user.Secret + }; + + var requestB = new LoginRequest + { + Identifier = user.Identifier, + Secret = user.Secret + }; + + var taskA = runtime.LoginAsync(flowA, executionA, requestA); + var taskB = runtime.LoginAsync(flowB, executionB, requestB); + + var results = await Task.WhenAll(taskA, taskB); + + results.Should().OnlyContain(x => x.IsSuccess); + + results[0].SessionId.Should().NotBeNull(); + results[1].SessionId.Should().NotBeNull(); + + results[0].SessionId.Should().NotBe(results[1].SessionId); + + var sessionA = + await store.GetSessionAsync( + results[0].SessionId!.Value); + + var sessionB = + await store.GetSessionAsync( + results[1].SessionId!.Value); + + sessionA.Should().NotBeNull(); + sessionB.Should().NotBeNull(); + + sessionA!.ChainId.Should() + .NotBe(sessionB!.ChainId); + + var chainA = + await store.GetChainAsync( + sessionA.ChainId); + + var chainB = + await store.GetChainAsync( + sessionB.ChainId); + + chainA.Should().NotBeNull(); + chainB.Should().NotBeNull(); + + chainA!.RootId.Should() + .Be(chainB!.RootId); + + var activeRoot = + await store.GetActiveRootByUserAsync( + user.UserKey); + + activeRoot.Should().NotBeNull(); + + activeRoot!.RootId.Should() + .Be(chainA.RootId); + + activeRoot.RootId.Should() + .Be(chainB.RootId); + } + + [Fact] + public async Task Concurrent_First_Logins_FromSameDevice_ShouldCreateSingleRootAndSingleChain() + { + var runtime = new TestAuthRuntime(); + + // + // Fresh user: no Root, Chain or Session exists. + // + var user = + await runtime.CreateLoginUserAsync(); + + var storeFactory = + runtime.Services.GetRequiredService(); + + var store = + storeFactory.Create(TenantKeys.Single); + + var rootBefore = + await store.GetActiveRootByUserAsync( + user.UserKey); + + rootBefore.Should().BeNull(); + + var flowA = + await runtime.CreateLoginFlowAsync(); + + var flowB = + await runtime.CreateLoginFlowAsync(); + + // + // Exactly the same device identity for both requests. + // + var device = + TestDevice.Default(); + + var executionA = new AuthExecutionContext + { + EffectiveClientProfile = + UAuthClientProfile.BlazorWasm, + + Device = device + }; + + var executionB = new AuthExecutionContext + { + EffectiveClientProfile = + UAuthClientProfile.BlazorWasm, + + Device = device + }; + + var requestA = new LoginRequest + { + Identifier = user.Identifier, + Secret = user.Secret + }; + + var requestB = new LoginRequest + { + Identifier = user.Identifier, + Secret = user.Secret + }; + + // + // Separate DI scopes, representing separate requests. + // + var taskA = + runtime.LoginAsync( + flowA, + executionA, + requestA); + + var taskB = + runtime.LoginAsync( + flowB, + executionB, + requestB); + + var results = + await Task.WhenAll( + taskA, + taskB); + + results.Should() + .OnlyContain(x => x.IsSuccess); + + results[0].SessionId.Should().NotBeNull(); + results[1].SessionId.Should().NotBeNull(); + + var sessionA = + await store.GetSessionAsync( + results[0].SessionId!.Value); + + var sessionB = + await store.GetSessionAsync( + results[1].SessionId!.Value); + + sessionA.Should().NotBeNull(); + sessionB.Should().NotBeNull(); + + // + // Same device must represent the same device lifecycle. + // + sessionA!.ChainId.Should() + .Be(sessionB!.ChainId); + + var chain = + await store.GetChainAsync( + sessionA.ChainId); + + chain.Should().NotBeNull(); + + // + // Only one authentication Root must exist/be active. + // + var activeRoot = + await store.GetActiveRootByUserAsync( + user.UserKey); + + activeRoot.Should().NotBeNull(); + + chain!.RootId.Should() + .Be(activeRoot!.RootId); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs index 688b979e..e4793a89 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -52,134 +52,218 @@ public async Task RotateAsync_WhenValidationIsInvalid_ReturnsFailedWithoutCreati } [Fact] - public async Task RotateAsync_WhenReuseIsDetectedForChain_RevokesChainAndReturnsFailed() + public async Task RotateAsync_WhenTokenIsNotFound_ReturnsFailedWithoutCreatingStoreOrIssuingTokens() { - var tenant = TenantKey.FromExternal("tenant-a"); - var sessionId = TestIds.Session("session-reuse-chain"); - var chainId = SessionChainId.New(); + var validator = CreateValidator( + RefreshTokenValidationResult.NotFound()); - var validator = new Mock(); - var storeFactory = new Mock(); - var store = new Mock(); - var issuer = new Mock(); + var storeFactory = + new Mock(); - validator - .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(RefreshTokenValidationResult.ReuseDetected( - tenant, - sessionId: sessionId, - tokenHash: "old-hash", - chainId: chainId, - userKey: UserKey.New())); - - storeFactory.Setup(x => x.Create(tenant)).Returns(store.Object); + var issuer = + new Mock(); var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, issuer.Object); - var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(sessionId)); + var result = await sut.RotateAsync( + CreateFlow(), + CreateContext()); result.Result.IsSuccess.Should().BeFalse(); result.Result.ReauthRequired.Should().BeTrue(); - store.Verify(x => x.RevokeByChainAsync(chainId, Now, It.IsAny()), Times.Once); - store.Verify(x => x.RevokeBySessionAsync( - It.IsAny(), - 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); } [Fact] - public async Task RotateAsync_WhenReuseIsDetectedWithoutChain_RevokesSessionAndReturnsFailed() + public async Task RotateAsync_WhenTokenIsExpired_ReturnsFailedWithoutMutatingStore() { var tenant = TenantKey.FromExternal("tenant-a"); - var sessionId = TestIds.Session("session-reuse-session"); - var validator = new Mock(); - var storeFactory = new Mock(); - var store = new Mock(); + var token = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: "expired-refresh-token-hash", + tenant: tenant, + userKey: UserKey.New(), + sessionId: TestIds.Session("expired-session"), + chainId: SessionChainId.New(), + createdAt: Now.AddDays(-8), + expiresAt: Now.AddMinutes(-1)); - validator - .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(RefreshTokenValidationResult.ReuseDetected( - tenant, - sessionId: sessionId, - tokenHash: "old-hash", - userKey: UserKey.New())); + var validator = CreateValidator( + RefreshTokenValidationResult.Expired(token)); + + var storeFactory = + new Mock(); - storeFactory.Setup(x => x.Create(tenant)).Returns(store.Object); + var issuer = + new Mock(); var sut = new RefreshTokenRotationService( validator.Object, storeFactory.Object, - Mock.Of()); + issuer.Object); - var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(sessionId)); + var result = await sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(token.SessionId)); result.Result.IsSuccess.Should().BeFalse(); - store.Verify(x => x.RevokeBySessionAsync(sessionId, Now, It.IsAny()), Times.Once); - store.Verify(x => x.RevokeByChainAsync( - It.IsAny(), - 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); } [Fact] - public async Task RotateAsync_WhenValidatedTokenHashIsMissing_ThrowsValidationException() + public async Task RotateAsync_WhenTokenIsAlreadyConsumed_ShouldRevokeCompromisedChainWithoutIssuingTokens() { - var tenant = TenantKey.FromExternal("tenant-a"); - var validation = RefreshTokenValidationResult.Valid( - tenant, - UserKey.New(), - TestIds.Session("session-missing-hash"), - tokenHash: null, - chainId: SessionChainId.New()); + var tenant = + TenantKey.FromExternal("tenant-a"); - var validator = new Mock(); - var storeFactory = new Mock(); + var chainId = + SessionChainId.New(); + + var sessionId = + TestIds.Session("session-consumed"); + + var token = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: "old-refresh-token-hash", + tenant: tenant, + userKey: UserKey.New(), + sessionId: sessionId, + chainId: chainId, + createdAt: Now.AddDays(-1), + expiresAt: Now.AddDays(6)) + .Revoke( + Now.AddMinutes(-1), + "replacement-refresh-token-hash"); + + var validator = + new Mock(); + + var store = + new Mock(); + + var storeFactory = + new Mock(); + + var issuer = + new Mock(); validator .Setup(x => x.ValidateAsync( It.IsAny(), It.IsAny())) - .ReturnsAsync(validation); + .ReturnsAsync( + RefreshTokenValidationResult.Consumed(token)); storeFactory .Setup(x => x.Create(tenant)) - .Returns(Mock.Of()); + .Returns(store.Object); - var sut = new RefreshTokenRotationService( - validator.Object, - storeFactory.Object, - Mock.Of()); + store + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns( + (Func action, + CancellationToken ct) => + action(ct)); - var act = () => sut.RotateAsync( - CreateFlow(tenant, multiTenant: true), - CreateContext(validation.SessionId)); + store + .Setup(x => x.RevokeByChainAsync( + chainId, + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var sut = + new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object); + + var result = + await sut.RotateAsync( + CreateFlow( + tenant, + multiTenant: true), + CreateContext(sessionId)); + + result.Result.IsSuccess.Should() + .BeFalse(); + + result.Result.ReauthRequired.Should() + .BeTrue(); + + storeFactory.Verify( + x => x.Create(tenant), + Times.Once); + + store.Verify( + x => x.RevokeByChainAsync( + chainId, + Now, + It.IsAny()), + Times.Once); + + issuer.Verify( + x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); - await act.Should().ThrowAsync(); + issuer.Verify( + x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); } [Fact] - public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithoutRevokingOldToken() + public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithoutConsumingOldToken() { var tenant = TenantKey.FromExternal("tenant-a"); var validation = CreateValidValidation(tenant); + var validator = CreateValidator(validation); - var store = new Mock(); + var store = CreateExecutableStore(); var storeFactory = CreateStoreFactory(tenant, store); var issuer = new Mock(); - issuer - .Setup(x => x.IssueAccessTokenAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(CreateAccessToken()); - issuer .Setup(x => x.IssueRefreshTokenAsync( It.IsAny(), @@ -188,34 +272,49 @@ public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithou It.IsAny())) .ReturnsAsync((RefreshTokenInfo?)null); - var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object); - var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + var result = await sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); - store.Verify(x => x.ExecuteAsync( - It.IsAny>(), - It.IsAny()), Times.Never); - store.Verify(x => x.RevokeAsync( + store.Verify(x => x.TryConsumeAsync( It.IsAny(), It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); + It.IsAny(), + It.IsAny()), + Times.Never); + store.Verify(x => x.StoreAsync( It.IsAny(), - It.IsAny()), Times.Never); + It.IsAny()), + Times.Never); + + issuer.Verify(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); } [Fact] - public async Task RotateAsync_WhenValid_RotatesAndStoresReplacementInSingleStoreExecution() + public async Task RotateAsync_WhenValid_AtomicallyRotatesRefreshToken() { var tenant = TenantKey.FromExternal("tenant-a"); var validation = CreateValidValidation(tenant); + var validator = CreateValidator(validation); - var store = new Mock(); + var store = CreateSuccessfulRotationStore(); var storeFactory = CreateStoreFactory(tenant, store); + var issuer = new Mock(); + var accessToken = CreateAccessToken(); var refreshToken = CreateRefreshTokenInfo(); @@ -234,85 +333,207 @@ public async Task RotateAsync_WhenValid_RotatesAndStoresReplacementInSingleStore It.IsAny())) .ReturnsAsync(refreshToken); - store - .Setup(x => x.ExecuteAsync( - It.IsAny>(), - It.IsAny())) - .Returns, CancellationToken>((action, ct) => action(ct)); - - var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object); - var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + var result = await sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); result.Result.IsSuccess.Should().BeTrue(); - result.Result.AccessToken.Should().BeSameAs(accessToken); - result.Result.RefreshToken.Should().BeSameAs(refreshToken); + + result.Result.AccessToken + .Should().BeSameAs(accessToken); + + result.Result.RefreshToken + .Should().BeSameAs(refreshToken); + result.Tenant.Should().Be(tenant); result.UserKey.Should().Be(validation.UserKey); result.SessionId.Should().Be(validation.SessionId); result.ChainId.Should().Be(validation.ChainId); - store.Verify(x => x.RevokeAsync( + store.Verify(x => x.TryConsumeAsync( validation.TokenHash!, Now, refreshToken.TokenHash, - It.IsAny()), Times.Once); + It.IsAny()), + Times.Once); store.Verify(x => x.StoreAsync( It.Is(token => token.TokenHash == refreshToken.TokenHash && token.Tenant == tenant && - token.UserKey == validation.UserKey!.Value && - token.SessionId == validation.SessionId!.Value && + token.UserKey == validation.UserKey && + token.SessionId == validation.SessionId && token.ChainId == validation.ChainId && token.CreatedAt == Now && token.ExpiresAt == refreshToken.ExpiresAt), - It.IsAny()), Times.Once); + It.IsAny()), + Times.Once); + + issuer.Verify(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.Is(ctx => + ctx.UserKey == validation.UserKey && + ctx.SessionId == validation.SessionId && + ctx.ChainId == validation.ChainId), + It.IsAny()), + Times.Once); } [Fact] public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForTokenIssuance() { var tenant = TenantKey.FromExternal("tenant-a"); - var validation = CreateValidValidation(tenant); - var issuer = new Mock(); + + var validation = + CreateValidValidation(tenant); + + var issuer = + new Mock(); + TokenIssuanceContext? captured = null; - SetupSuccessfulIssuer(issuer, ctx => captured = ctx); + SetupSuccessfulIssuer( + issuer, + ctx => captured = ctx); - var store = CreateExecutableStore(); - var sut = new RefreshTokenRotationService( - CreateValidator(validation).Object, - CreateStoreFactory(tenant, store).Object, - issuer.Object); + var store = + CreateSuccessfulRotationStore(); + + var sut = + new RefreshTokenRotationService( + CreateValidator(validation).Object, + CreateStoreFactory(tenant, store).Object, + issuer.Object); + + var result = await sut.RotateAsync( + CreateFlow( + tenant, + multiTenant: true), + CreateContext(validation.SessionId)); - await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + result.Result.IsSuccess.Should().BeTrue(); captured.Should().NotBeNull(); - captured!.Tenant.Should().Be(tenant); + + captured!.Tenant.Should() + .Be(tenant); } [Fact] public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIssuance() { - var validation = CreateValidValidation(TenantKey.Single); - var issuer = new Mock(); + var validation = + CreateValidValidation(TenantKey.Single); + + var issuer = + new Mock(); + TokenIssuanceContext? captured = null; - SetupSuccessfulIssuer(issuer, ctx => captured = ctx); + SetupSuccessfulIssuer( + issuer, + ctx => captured = ctx); + + var store = + CreateSuccessfulRotationStore(); + + var sut = + new RefreshTokenRotationService( + CreateValidator(validation).Object, + CreateStoreFactory( + TenantKey.Single, + store).Object, + issuer.Object); + + var result = await sut.RotateAsync( + CreateFlow( + TenantKey.Single, + multiTenant: false), + CreateContext(validation.SessionId)); + + result.Result.IsSuccess.Should().BeTrue(); + + captured.Should().NotBeNull(); + captured!.Tenant.Should() + .Be(TenantKey.Single); + } + + [Fact] + public async Task RotateAsync_WhenAtomicConsumeLosesRace_ReturnsFailedWithoutStoringReplacement() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + + var validator = CreateValidator(validation); var store = CreateExecutableStore(); + var storeFactory = CreateStoreFactory(tenant, store); + + var issuer = new Mock(); + + var accessToken = CreateAccessToken(); + var refreshToken = CreateRefreshTokenInfo(); + + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(accessToken); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync(refreshToken); + + store + .Setup(x => x.TryConsumeAsync( + validation.TokenHash!, + Now, + refreshToken.TokenHash, + It.IsAny())) + .ReturnsAsync(false); + var sut = new RefreshTokenRotationService( - CreateValidator(validation).Object, - CreateStoreFactory(TenantKey.Single, store).Object, + validator.Object, + storeFactory.Object, issuer.Object); - await sut.RotateAsync(CreateFlow(TenantKey.Single, multiTenant: false), CreateContext(validation.SessionId)); + var result = await sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); - captured.Should().NotBeNull(); - captured!.Tenant.Should().Be(TenantKey.Single); + result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); + + store.Verify(x => x.TryConsumeAsync( + validation.TokenHash!, + Now, + refreshToken.TokenHash, + It.IsAny()), + Times.Once); + + store.Verify(x => x.StoreAsync( + 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) { var validator = new Mock(); @@ -334,21 +555,53 @@ private static Mock CreateStoreFactory( private static Mock CreateExecutableStore() { var store = new Mock(); + store .Setup(x => x.ExecuteAsync( It.IsAny>(), It.IsAny())) - .Returns, CancellationToken>((action, ct) => action(ct)); + .Returns, CancellationToken>( + (action, ct) => action(ct)); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + (action, ct) => action(ct)); + + return store; + } + + private static Mock CreateSuccessfulRotationStore() + { + var store = CreateExecutableStore(); + + store + .Setup(x => x.TryConsumeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + return store; } private static RefreshTokenValidationResult CreateValidValidation(TenantKey tenant) - => RefreshTokenValidationResult.Valid( - tenant, - UserKey.New(), - TestIds.Session("rotation-session"), - "old-refresh-token-hash", - SessionChainId.New()); + { + var token = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: "old-refresh-token-hash", + tenant: tenant, + userKey: UserKey.New(), + sessionId: TestIds.Session("rotation-session"), + chainId: SessionChainId.New(), + createdAt: Now.AddDays(-1), + expiresAt: Now.AddDays(7)); + + return RefreshTokenValidationResult.Valid(token, token.TokenHash); + } private static RefreshTokenRotationContext CreateContext(AuthSessionId? expectedSessionId = null) => new() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs index e5b926f7..1ab99a58 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -388,12 +388,9 @@ public async Task RevokeUserSessionAsync_WhenSessionBelongsToDifferentUser_DoesN caller, session.SessionId); - await act.Should() - .ThrowAsync(); - - var persisted = - await store.GetSessionAsync(session.SessionId); + await act.Should().ThrowAsync(); + var persisted = await store.GetSessionAsync(session.SessionId); persisted.Should().NotBeNull(); persisted!.IsRevoked.Should().BeFalse(); } @@ -716,43 +713,34 @@ public async Task RevokeRootAsync_RevokesRootAndItsChains() var root = await CreateRootAsync(store, user); - var firstChain = CreateChain( - root, - Now.AddMinutes(-20)); + var firstChain = CreateChain(root, Now.AddMinutes(-20)); - var secondChain = CreateChain( - root, - Now.AddMinutes(-10)); + var secondChain = CreateChain(root, Now.AddMinutes(-10)); await store.CreateChainAsync(firstChain); await store.CreateChainAsync(secondChain); - await sut.RevokeRootAsync( - Context(user), - user); + await sut.RevokeRootAsync(Context(user), user); + + var activeRoot = await store.GetActiveRootByUserAsync(user); - var persistedRoot = - await store.GetRootByUserAsync(user); + activeRoot.Should().BeNull(); + + var persistedRoot = await store.GetRootByIdAsync(root.RootId); persistedRoot.Should().NotBeNull(); persistedRoot!.IsRevoked.Should().BeTrue(); persistedRoot.RevokedAt.Should().Be(Now); - (await store.GetChainAsync(firstChain.ChainId))! - .IsRevoked.Should().BeTrue(); - - (await store.GetChainAsync(secondChain.ChainId))! - .IsRevoked.Should().BeTrue(); + (await store.GetChainAsync(firstChain.ChainId))!.IsRevoked.Should().BeTrue(); + (await store.GetChainAsync(secondChain.ChainId))!.IsRevoked.Should().BeTrue(); } // --------------------------------------------------------------------- // Infrastructure / Helpers // --------------------------------------------------------------------- - private static ( - SessionApplicationService Sut, - ISessionStore Store) - CreateSut() + private static (SessionApplicationService Sut, ISessionStore Store) CreateSut() { var factory = new InMemorySessionStoreFactory(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs index 9a0ca867..f67f92b3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs @@ -129,7 +129,7 @@ public async Task ValidateSessionAsync_WhenChainDoesNotExist_ReturnsRevoked() result.ChainId.Should().Be(fixture.Chain.ChainId); fixture.Store.Verify( - x => x.GetRootByUserAsync( + x => x.GetActiveRootByUserAsync( It.IsAny(), It.IsAny()), Times.Never); @@ -156,7 +156,7 @@ public async Task ValidateSessionAsync_WhenChainIsRevoked_ReturnsRevoked() result.State.Should().Be(SessionState.Revoked); fixture.Store.Verify( - x => x.GetRootByUserAsync( + x => x.GetActiveRootByUserAsync( It.IsAny(), It.IsAny()), Times.Never); @@ -176,7 +176,7 @@ public async Task ValidateSessionAsync_WhenChainIsAbsolutelyExpired_ReturnsExpir result.State.Should().Be(SessionState.Expired); fixture.Store.Verify( - x => x.GetRootByUserAsync( + x => x.GetActiveRootByUserAsync( It.IsAny(), It.IsAny()), Times.Never); @@ -197,7 +197,7 @@ public async Task ValidateSessionAsync_WhenChainIdleTimeoutIsExceeded_ReturnsExp result.State.Should().Be(SessionState.Expired); fixture.Store.Verify( - x => x.GetRootByUserAsync( + x => x.GetActiveRootByUserAsync( It.IsAny(), It.IsAny()), Times.Never); @@ -215,7 +215,7 @@ public async Task ValidateSessionAsync_WhenRootDoesNotExist_ReturnsRevoked() SetupSessionAndChain(fixture); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, It.IsAny())) .ReturnsAsync((UAuthSessionRoot?)null); @@ -244,7 +244,7 @@ public async Task ValidateSessionAsync_WhenRootIsRevoked_ReturnsRevoked() SetupSessionAndChain(fixture); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, It.IsAny())) .ReturnsAsync(revokedRoot); @@ -276,7 +276,7 @@ public async Task ValidateSessionAsync_WhenChainBelongsToDifferentRoot_ReturnsSe SetupSessionAndChain(fixture); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, It.IsAny())) .ReturnsAsync(differentRoot); @@ -306,7 +306,7 @@ public async Task ValidateSessionAsync_WhenSecurityVersionDoesNotMatch_ReturnsSe SetupSessionAndChain(fixture); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, It.IsAny())) .ReturnsAsync(updatedRoot); @@ -438,7 +438,7 @@ public async Task ValidateSessionAsync_PropagatesCancellationToken() .ReturnsAsync(fixture.Chain); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, ct)) .ReturnsAsync(fixture.Root); @@ -465,7 +465,7 @@ public async Task ValidateSessionAsync_PropagatesCancellationToken() Times.Once); fixture.Store.Verify( - x => x.GetRootByUserAsync(fixture.User, ct), + x => x.GetActiveRootByUserAsync(fixture.User, ct), Times.Once); fixture.ClaimsProvider.Verify( @@ -502,7 +502,7 @@ private static void SetupValidAggregate(Fixture fixture) SetupSessionAndChain(fixture); fixture.Store - .Setup(x => x.GetRootByUserAsync( + .Setup(x => x.GetActiveRootByUserAsync( fixture.User, It.IsAny())) .ReturnsAsync(fixture.Root); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs index 903be7eb..e2bc8e60 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs @@ -957,10 +957,18 @@ await store.ExecuteAsync( at, ct)); - var root = await store.GetRootByUserAsync( - graph.UserA); + var activeRoot = + await store.GetActiveRootByUserAsync( + graph.UserA); + + activeRoot.Should().BeNull(); + + var root = + await store.GetRootByIdAsync( + graph.RootA.RootId); root.Should().NotBeNull(); + root!.IsRevoked.Should().BeTrue(); root.RevokedAt.Should().Be(at); } @@ -979,8 +987,15 @@ await store.ExecuteAsync( Now.AddMinutes(20), ct)); - var root = await store.GetRootByUserAsync( - graph.UserA); + var activeRoot = + await store.GetActiveRootByUserAsync( + graph.UserA); + + activeRoot.Should().BeNull(); + + var root = + await store.GetRootByIdAsync( + graph.RootA.RootId); root.Should().NotBeNull(); @@ -1057,7 +1072,7 @@ await store.ExecuteAsync( Now.AddMinutes(20), ct)); - var rootB = await store.GetRootByUserAsync( + var rootB = await store.GetActiveRootByUserAsync( graph.UserB); var chainB1 = await store.GetChainAsync( diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs index 79e88aef..fa9ad3c1 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs @@ -53,7 +53,7 @@ await store.ExecuteAsync( ct => store.CreateRootAsync(root, ct)); var result = - await store.GetRootByUserAsync(user); + await store.GetActiveRootByUserAsync(user); result.Should().NotBeNull(); @@ -80,7 +80,7 @@ public async Task GetRootByUserAsync_WhenMissing_ReturnsNull() var store = db.CreateStore(Tenant); var result = - await store.GetRootByUserAsync( + await store.GetActiveRootByUserAsync( UserKey.New()); result.Should().BeNull(); @@ -149,12 +149,11 @@ public async Task CreateRootAsync_WhenVersionIsNotZero_IsRejected() root, ct)); - await act.Should() - .ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] - public async Task CreateRootAsync_WhenUserAlreadyHasRoot_ThrowsConcurrency() + public async Task CreateRootAsync_WhenUserAlreadyHasActiveRoot_ThrowsConflict() { await using var db = await CreateDatabaseAsync(); var store = db.CreateStore(Tenant); @@ -166,11 +165,10 @@ await CreateRootAsync( Tenant, user); - var secondRoot = - UAuthSessionRoot.Create( - Tenant, - user, - Now.AddMinutes(1)); + var secondRoot = UAuthSessionRoot.Create( + Tenant, + user, + Now.AddMinutes(1)); var act = () => store.ExecuteAsync( ct => store.CreateRootAsync( @@ -178,7 +176,7 @@ await CreateRootAsync( ct)); await act.Should() - .ThrowAsync(); + .ThrowAsync(); } // ------------------------------------------------------------ @@ -210,7 +208,7 @@ await store.ExecuteAsync( ct)); var result = - await store.GetRootByUserAsync( + await store.GetActiveRootByUserAsync( root.UserKey); result.Should().NotBeNull(); @@ -306,10 +304,18 @@ await store.ExecuteAsync( revokedAt, ct)); - var result = - await store.GetRootByUserAsync( + // Revoked root is no longer active. + var active = + await store.GetActiveRootByUserAsync( root.UserKey); + active.Should().BeNull(); + + // But the historical root must still exist. + var result = + await store.GetRootByIdAsync( + root.RootId); + result.Should().NotBeNull(); result!.IsRevoked.Should().BeTrue(); @@ -361,10 +367,11 @@ await store.ExecuteAsync( ct)); var afterFirst = - await store.GetRootByUserAsync( - root.UserKey); + await store.GetRootByIdAsync( + root.RootId); afterFirst.Should().NotBeNull(); + afterFirst!.IsRevoked.Should().BeTrue(); await store.ExecuteAsync( ct => store.RevokeRootAsync( @@ -373,12 +380,13 @@ await store.ExecuteAsync( ct)); var afterSecond = - await store.GetRootByUserAsync( - root.UserKey); + await store.GetRootByIdAsync( + root.RootId); afterSecond.Should().NotBeNull(); + afterSecond!.IsRevoked.Should().BeTrue(); - afterSecond!.RevokedAt + afterSecond.RevokedAt .Should().Be(firstRevokedAt); afterSecond.UpdatedAt @@ -386,7 +394,7 @@ await store.GetRootByUserAsync( afterSecond.SecurityVersion .Should().Be( - afterFirst!.SecurityVersion); + afterFirst.SecurityVersion); afterSecond.Version .Should().Be( @@ -420,10 +428,10 @@ await CreateRootAsync( user); var fromA = - await storeA.GetRootByUserAsync(user); + await storeA.GetActiveRootByUserAsync(user); var fromB = - await storeB.GetRootByUserAsync(user); + await storeB.GetActiveRootByUserAsync(user); fromA.Should().NotBeNull(); fromA!.RootId.Should().Be(root.RootId); @@ -490,9 +498,8 @@ public async Task CreateRootAsync_WhenRootBelongsToDifferentTenant_IsRejected() rootB, ct)); - await act.Should() - .ThrowAsync() - .WithMessage("Tenant mismatch."); + var exception = await act.Should().ThrowAsync(); + exception.Which.Code.Should().Be("Tenant mismatch."); } [Fact] @@ -524,9 +531,55 @@ public async Task SaveRootAsync_WhenRootBelongsToDifferentTenant_IsRejected() expectedVersion: rootB.Version, ct)); - await act.Should() - .ThrowAsync() - .WithMessage("Tenant mismatch."); + var exception = await act.Should().ThrowAsync(); + exception.Which.Code.Should().Be("Tenant mismatch."); + } + + [Fact] + public async Task CreateRootAsync_WhenPreviousRootIsRevoked_AllowsNewRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var firstRoot = await CreateRootAsync( + store, + Tenant, + user); + + await store.ExecuteAsync(async ct => + { + await store.RevokeRootCascadeAsync( + user, + Now.AddMinutes(1), + ct); + }); + + var secondRoot = UAuthSessionRoot.Create( + Tenant, + user, + Now.AddMinutes(2)); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync( + secondRoot, + ct); + }); + + var active = + await store.GetActiveRootByUserAsync(user); + + active.Should().NotBeNull(); + active!.RootId.Should().Be(secondRoot.RootId); + active.IsRevoked.Should().BeFalse(); + + var historical = + await store.GetRootByIdAsync(firstRoot.RootId); + + historical.Should().NotBeNull(); + historical!.IsRevoked.Should().BeTrue(); } // ------------------------------------------------------------ @@ -539,17 +592,11 @@ public async Task GetRootByUserAsync_WhenCancelled_Throws() await using var db = await CreateDatabaseAsync(); var store = db.CreateStore(Tenant); - using var cts = - new CancellationTokenSource(); + using var cts = new CancellationTokenSource(); cts.Cancel(); - var act = () => - store.GetRootByUserAsync( - UserKey.New(), - cts.Token); - - await act.Should() - .ThrowAsync(); + var act = () => store.GetActiveRootByUserAsync(UserKey.New(), cts.Token); + await act.Should().ThrowAsync(); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs index 9fe34862..1a86d210 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs @@ -477,7 +477,7 @@ await store.ExecuteAsync( ct => store.CreateSessionAsync(a2, ct)); var root = - await store.GetRootByUserAsync(user); + await store.GetActiveRootByUserAsync(user); root.Should().NotBeNull(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs index 3348d13a..ab7e783c 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; @@ -78,7 +79,7 @@ public async Task StoreAsync_WhenTokenBelongsToDifferentTenant_IsRejected() ct => store.StoreAsync(token, ct)); await act.Should() - .ThrowAsync(); + .ThrowAsync(); } [Fact] @@ -226,6 +227,240 @@ await store.ExecuteAsync( .Should().Be("replacement-1"); } + // ============================================================ + // ATOMIC CONSUME / ROTATION + // ============================================================ + + [Fact] + public async Task TryConsumeAsync_WhenTokenIsActive_ConsumesTokenAndReturnsTrue() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "consume-active"); + + await StoreAsync(store, token); + + var consumedAt = Now.AddMinutes(10); + + var consumed = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + token.TokenHash, + consumedAt, + "replacement-token", + ct)); + + consumed.Should().BeTrue(); + + var persisted = + await store.FindByHashAsync(token.TokenHash); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.RevokedAt.Should().Be(consumedAt); + + persisted.ReplacedByTokenHash + .Should().Be("replacement-token"); + } + + [Fact] + public async Task TryConsumeAsync_WhenTokenAlreadyConsumed_ReturnsFalseAndDoesNotMutate() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "consume-twice"); + + await StoreAsync(store, token); + + var firstConsumedAt = Now.AddMinutes(10); + + var first = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + token.TokenHash, + firstConsumedAt, + "replacement-1", + ct)); + + first.Should().BeTrue(); + + var second = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + token.TokenHash, + Now.AddMinutes(20), + "replacement-2", + ct)); + + second.Should().BeFalse(); + + var persisted = + await store.FindByHashAsync(token.TokenHash); + + persisted.Should().NotBeNull(); + + persisted!.RevokedAt + .Should().Be(firstConsumedAt); + + persisted.ReplacedByTokenHash + .Should().Be("replacement-1"); + } + + [Fact] + public async Task TryConsumeAsync_WhenTokenDoesNotExist_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + "missing-token", + Now, + "replacement-token", + ct)); + + result.Should().BeFalse(); + } + + [Fact] + public async Task TryConsumeAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var tokenA = CreateToken( + TenantA, + "shared-consume-hash"); + + var tokenB = CreateToken( + TenantB, + "shared-consume-hash"); + + await StoreAsync(storeA, tokenA); + await StoreAsync(storeB, tokenB); + + var consumed = await storeA.ExecuteAsync( + ct => storeA.TryConsumeAsync( + tokenA.TokenHash, + Now.AddMinutes(10), + "replacement-a", + ct)); + + consumed.Should().BeTrue(); + + var persistedA = + await storeA.FindByHashAsync(tokenA.TokenHash); + + var persistedB = + await storeB.FindByHashAsync(tokenB.TokenHash); + + persistedA!.IsRevoked.Should().BeTrue(); + + persistedB!.IsRevoked.Should().BeFalse(); + persistedB.RevokedAt.Should().BeNull(); + persistedB.ReplacedByTokenHash.Should().BeNull(); + } + + [Fact] + public async Task TryConsumeAsync_WhenCalledConcurrently_AllowsExactlyOneConsumer() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "concurrent-consume"); + + await StoreAsync(storeA, token); + + var consumedAt = Now.AddMinutes(10); + + var taskA = storeA.ExecuteAsync( + ct => storeA.TryConsumeAsync( + token.TokenHash, + consumedAt, + "replacement-a", + ct)); + + var taskB = storeB.ExecuteAsync( + ct => storeB.TryConsumeAsync( + token.TokenHash, + consumedAt, + "replacement-b", + ct)); + + var results = await Task.WhenAll( + taskA, + taskB); + + results.Count(x => x) + .Should().Be(1); + + results.Count(x => !x) + .Should().Be(1); + + var persisted = + await storeA.FindByHashAsync( + token.TokenHash); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + + persisted.ReplacedByTokenHash.Should() + .BeOneOf( + "replacement-a", + "replacement-b"); + } + + [Fact] + public async Task TryConsumeAsync_WhenAlreadyConsumed_DoesNotOverwriteWinningReplacement() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "winning-replacement"); + + await StoreAsync(store, token); + + var winningTime = Now.AddMinutes(10); + + var winner = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + token.TokenHash, + winningTime, + "winner-replacement", + ct)); + + winner.Should().BeTrue(); + + var loser = await store.ExecuteAsync( + ct => store.TryConsumeAsync( + token.TokenHash, + Now.AddMinutes(20), + "loser-replacement", + ct)); + + loser.Should().BeFalse(); + + var persisted = + await store.FindByHashAsync(token.TokenHash); + + persisted!.RevokedAt.Should() + .Be(winningTime); + + persisted.ReplacedByTokenHash.Should() + .Be("winner-replacement"); + } + // ============================================================ // REVOKE BY SESSION // ============================================================