diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs new file mode 100644 index 00000000..6c48f9ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Credential/ICredentialSessionPolicy.cs @@ -0,0 +1,8 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ICredentialSessionPolicy +{ + CredentialSessionBehavior Resolve(CredentialChangeReason reason); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs b/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs new file mode 100644 index 00000000..da589672 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/CredentialChangeReason.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum CredentialChangeReason +{ + SelfChange = 0, + AdminChange = 10, + Recovery = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs new file mode 100644 index 00000000..6665f0ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/CredentialSessionBehavior.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Options; + +public enum CredentialSessionBehavior +{ + PreserveAll = 0, + PreserveCurrent = 10, + RevokeAll = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs index 7d5bedd6..41e7e278 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -72,6 +72,10 @@ private static void Validate(UAuthResponseOptions response) private static void ValidateDelivery(CredentialResponseOptions delivery) { + // TODO: + if (delivery.Mode == TokenResponseMode.Body) + throw new NotSupportedException($"Body credential delivery is not supported for '{delivery.Kind}'."); + if (delivery.Mode == TokenResponseMode.Cookie && delivery.Cookie is null) { throw new InvalidOperationException($"Credential '{delivery.Kind}' is configured as Cookie but no cookie options were bound."); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index d3566994..650a6837 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -263,6 +263,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddScoped(); services.TryAddSingleton(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs new file mode 100644 index 00000000..d5dcfbd9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthCredentialSessionPolicy.cs @@ -0,0 +1,30 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UAuthCredentialSessionPolicy : ICredentialSessionPolicy +{ + private readonly UAuthCredentialSessionPolicyOptions _options; + + public UAuthCredentialSessionPolicy(IOptions options) + { + _options = options.Value.CredentialSessionPolicy; + } + + public CredentialSessionBehavior Resolve(CredentialChangeReason reason) + { + return reason switch + { + CredentialChangeReason.SelfChange => _options.OnSelfChange, + + CredentialChangeReason.AdminChange => _options.OnAdminChange, + + CredentialChangeReason.Recovery => _options.OnRecovery, + + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null) + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs new file mode 100644 index 00000000..b495a0f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCredentialSessionPolicyOptions.cs @@ -0,0 +1,37 @@ +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCredentialSessionPolicyOptions +{ + /// + /// Session behavior when a user changes their own credential. + /// By default, the current session chain is preserved. + /// + public CredentialSessionBehavior OnSelfChange { get; set; } = CredentialSessionBehavior.PreserveCurrent; + + /// + /// Session behavior when an administrator changes a user's credential. + /// By default, all session chains are revoked. + /// + public CredentialSessionBehavior OnAdminChange { get; set; } = CredentialSessionBehavior.RevokeAll; + + /// + /// Session behavior after successful credential recovery. + /// By default, all session chains are revoked. + ///

+ /// Preserving the current session don't work well with recovery flows, because the current session is usually the one that was lost and is being recovered. + /// In this case, RevokeAll option is using instead of PreserveCurrent automatically. + ///
+ public CredentialSessionBehavior OnRecovery { get; set; } = CredentialSessionBehavior.RevokeAll; + + internal UAuthCredentialSessionPolicyOptions Clone() + { + return new UAuthCredentialSessionPolicyOptions + { + OnSelfChange = OnSelfChange, + OnAdminChange = OnAdminChange, + OnRecovery = OnRecovery + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 7e91f86b..34af5b93 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -81,6 +81,11 @@ public sealed class UAuthServerOptions public UAuthResetOptions ResetCredential { get; init; } = new(); + /// + /// Controls session invalidation after credential changes and recovery. + /// + public UAuthCredentialSessionPolicyOptions CredentialSessionPolicy { get; init; } = new(); + public UAuthHubOptions Hub { get; set; } = new(); /// @@ -151,6 +156,7 @@ internal UAuthServerOptions Clone() PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), + CredentialSessionPolicy = CredentialSessionPolicy.Clone(), Hub = Hub.Clone(), SessionResolution = SessionResolution.Clone(), Identifiers = Identifiers.Clone(), diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index f84692c5..2c52e1b3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -173,7 +173,14 @@ public async Task CreateRoleAsync(HttpContext ctx) var role = await _roles.CreateAsync(accessContext, req.Name, req.Permissions, ctx.RequestAborted); - return Results.Ok(role); + return Results.Ok(new RoleInfo + { + Id = role.Id, + Name = role.Name, + Permissions = role.Permissions.ToArray(), + CreatedAt = role.CreatedAt, + UpdatedAt = role.UpdatedAt + }); } public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs index 5a0fbeb2..dd70b892 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -110,7 +110,7 @@ public async Task> CompleteResetUserAsync(Us public async Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request) { - var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/credentials/delete")); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/delete"), request); return UAuthResultMapper.From(raw); } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index eea71c18..42a889ee 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -441,7 +441,7 @@ public async Task> LogoutMyDeviceAsync(LogoutDeviceReq { var result = UAuthResultMapper.FromJson(raw); - if (result.Value?.CurrentChain == true) + if (result.IsSuccess && result.Value?.CurrentChain == true) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs index 80e5ce01..116dea77 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -42,7 +42,7 @@ public async Task> RevokeMyChainAsync(SessionChainId c var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId.Value}/revoke")); var result = UAuthResultMapper.FromJson(raw); - if (result.Value?.CurrentChain == true) + if (result.IsSuccess && result.Value?.CurrentChain == true) { await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs index 58220bab..8393787a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -51,8 +51,7 @@ public async Task DeleteMeAsync() if (result.IsSuccess) { - await _events.PublishAsync( - new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); } return result; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs index cd214a18..3ef63946 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -70,12 +70,14 @@ public async Task ChangeSecretAsync(HttpContext ctx) return Results.Ok(result); } + public async Task RevokeAsync(HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -83,7 +85,14 @@ public async Task RevokeAsync(HttpContext ctx) resource: "credentials", resourceId: flow.UserKey!.Value); - await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.RevokeAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); + return Results.NoContent(); } @@ -173,13 +182,16 @@ public async Task ChangeSecretAdminAsync(UserKey userKey, HttpContext c return Results.Ok(result); } + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -187,18 +199,27 @@ public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) resource: "credentials", resourceId: userKey.Value); - await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.RevokeAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); return Results.NoContent(); } + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) { var flow = _authFlow.Current; + if (!flow.IsAuthenticated) return Results.Unauthorized(); - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -206,17 +227,25 @@ public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) resource: "credentials", resourceId: userKey.Value); - await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); + var result = await _credentials.DeleteAsync( + accessContext, + request, + ctx.RequestAborted); + + if (!result.IsSuccess) + return MapCredentialActionFailure(result); return Results.NoContent(); } + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -224,16 +253,19 @@ public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx resource: "credentials", resourceId: userKey.Value); - await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) { if (!TryGetSelf(out var flow, out var error)) return error!; - var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + var request = await ctx.ReadJsonAsync( + ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -241,8 +273,9 @@ public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext resource: "credentials", resourceId: userKey.Value); - await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); - return Results.NoContent(); + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) @@ -257,4 +290,23 @@ private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) error = null; return true; } + + + private static IResult MapCredentialActionFailure(CredentialActionResult result) + { + var reason = result.Error ?? "credential_action_failed"; + + return reason switch + { + "credential_not_found" => Results.Problem( + statusCode: StatusCodes.Status404NotFound, + title: "Credential not found", + detail: reason), + + _ => Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Credential action failed", + detail: reason) + }; + } } diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs index 3a56ef57..65a462aa 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -3,6 +3,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference.Internal; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -25,6 +26,7 @@ internal sealed class CredentialManagementService : ICredentialManagementService private readonly ILoginIdentifierResolver _identifierResolver; private readonly ISessionStoreFactory _sessionFactory; private readonly ICredentialResetNotifier _resetNotifier; + private readonly ICredentialSessionPolicy _credentialSessionPolicy; private readonly UAuthServerOptions _options; private readonly IClock _clock; @@ -39,6 +41,7 @@ public CredentialManagementService( ILoginIdentifierResolver identifierResolver, ISessionStoreFactory sessionFactory, ICredentialResetNotifier resetNotifier, + ICredentialSessionPolicy credentialSessionPolicy, IOptions options, IClock clock) { @@ -52,6 +55,7 @@ public CredentialManagementService( _identifierResolver = identifierResolver; _sessionFactory = sessionFactory; _resetNotifier = resetNotifier; + _credentialSessionPolicy = credentialSessionPolicy; _options = options.Value; _clock = clock; } @@ -156,15 +160,10 @@ public async Task ChangeSecretAsync(AccessContext contex var updated = pwd.ChangeSecret(newHash, now); await store.SaveAsync(updated, oldVersion, innerCt); - var sessionStore = _sessionFactory.Create(context.ResourceTenant); - if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) - { - await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, innerCt); - } - else - { - await sessionStore.RevokeAllChainsAsync(subjectUser, now, innerCt); - } + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant, subjectUser, context.IsSelfAction + ? CredentialChangeReason.SelfChange + : CredentialChangeReason.AdminChange, + context.ActorChainId, now, innerCt); return ChangeCredentialResult.Success(pwd.Type); }); @@ -213,6 +212,13 @@ public async Task BeginResetAsync(AccessContext cont if (string.IsNullOrWhiteSpace(request.Identifier)) throw new UAuthValidationException("identifier_required"); + // TODO: Support other credential types in the future (e.g., Totp, Passkey, etc.) + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("credential_reset_type_not_supported"); + + if (request.ResetCodeType is not (ResetCodeType.Token or ResetCodeType.Code)) + throw new UAuthValidationException("invalid_reset_code_type"); + var now = _clock.UtcNow; // Anonymous callers must not control the proof's lifetime. var validity = _options.ResetCredential.TokenValidity; @@ -231,23 +237,23 @@ public async Task BeginResetAsync(AccessContext cont if (resolution?.UserKey is not UserKey userKey) return result; // Enumeration protection - var state = await _authenticationSecurityManager - .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + var state = await _authenticationSecurityManager.GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); - string token; + // An existing active reset must not be observable + // through the anonymous endpoint. + if (state.HasActiveReset(now) && context.IsAnonymousContext) + return result; - if (request.ResetCodeType == ResetCodeType.Token) + var token = request.ResetCodeType switch { - token = _tokenGenerator.Generate(); - } - else if (request.ResetCodeType == ResetCodeType.Code) - { - token = _numericCodeGenerator.Generate(_options.ResetCredential.CodeLength); - } - else - { - throw new UAuthValidationException("invalid_reset_code_type"); - } + ResetCodeType.Token => _tokenGenerator.Generate(), + + ResetCodeType.Code => _numericCodeGenerator.Generate( + _options.ResetCredential.CodeLength), + + _ => throw new UAuthValidationException( + "invalid_reset_code_type") + }; var tokenHash = _tokenHasher.Hash(token); @@ -285,6 +291,10 @@ public async Task CompleteResetAsync(AccessContext conte if (string.IsNullOrWhiteSpace(request.NewSecret)) throw new UAuthValidationException("new_secret_required"); + // TODO: Support other credential types in the future (e.g., Totp, Passkey, etc.) + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("credential_reset_type_not_supported"); + var now = _clock.UtcNow; var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); @@ -305,6 +315,10 @@ public async Task CompleteResetAsync(AccessContext conte var version2 = state.SecurityVersion; var cleared = state.ClearReset(); await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + + if (context.IsAnonymousContext) + return CredentialActionResult.Success(); + throw new UAuthConflictException("reset_expired"); } @@ -341,7 +355,22 @@ public async Task CompleteResetAsync(AccessContext conte } if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) - throw new UAuthValidationException("credential_secret_same"); + { + if (!context.IsAnonymousContext) + throw new UAuthValidationException("credential_secret_same"); + + // Valid reset token, but password is unchanged. + // Consume the token to preserve single-use semantics. + var version = state.SecurityVersion; + var consumed = state.ConsumeReset(now); + + await _authenticationSecurityManager.UpdateAsync( + consumed, + version, + innerCt); + + return CredentialActionResult.Success(); + } var version3 = state.SecurityVersion; state = state.ConsumeReset(now); @@ -352,6 +381,7 @@ public async Task CompleteResetAsync(AccessContext conte var updated = pwd.ChangeSecret(newHash, now); await store.SaveAsync(updated, oldVersion, innerCt); + await ApplyCredentialSessionPolicyAsync(context.ResourceTenant ,userKey, CredentialChangeReason.Recovery, actorChainId: null, now, innerCt); return CredentialActionResult.Success(); }); @@ -421,4 +451,35 @@ async Task IUserCredentialsInternalService.DeleteInterna await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, ct); return CredentialActionResult.Success(); } + + // ---------------------------------------- + // HELPERS + // ---------------------------------------- + private async Task ApplyCredentialSessionPolicyAsync(TenantKey tenant, UserKey subjectUser, CredentialChangeReason reason, + SessionChainId? actorChainId, DateTimeOffset now, CancellationToken ct) + { + var behavior = _credentialSessionPolicy.Resolve(reason); + + if (behavior == CredentialSessionBehavior.PreserveAll) + return; + + var sessionStore = _sessionFactory.Create(tenant); + + switch (behavior) + { + case CredentialSessionBehavior.PreserveCurrent + when reason == CredentialChangeReason.SelfChange && actorChainId is SessionChainId chainId: + await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, ct); + break; + + case CredentialSessionBehavior.PreserveCurrent: + case CredentialSessionBehavior.RevokeAll: + + await sessionStore.RevokeAllChainsAsync(subjectUser, now, ct); + break; + + default: + throw new InvalidOperationException($"Unsupported credential session behavior: {behavior}"); + } + } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs index 453043e0..a33c7790 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserSummaryQueryStore.cs @@ -127,37 +127,48 @@ public async Task> QueryAsync(UserQuery query, Cancella { nameof(UserSummary.DisplayName) => query.Descending - ? summaries.OrderByDescending(x => x.DisplayName).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.DisplayName).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), nameof(UserSummary.UserName) => query.Descending - ? summaries.OrderByDescending(x => x.UserName).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.UserName).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), nameof(UserSummary.PrimaryEmail) => query.Descending - ? summaries.OrderByDescending(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.PrimaryEmail).ThenBy(x => x.CreatedAt), + ? summaries.OrderByDescending(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), nameof(UserSummary.PrimaryPhone) => query.Descending - ? summaries.OrderByDescending(x => x.PrimaryPhone).ThenBy(x => x.CreatedAt) - : summaries.OrderBy(x => x.PrimaryPhone).ThenBy(x => x.CreatedAt), - - nameof(UserSummary.CreatedAt) => - query.Descending - ? summaries.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.UserKey) - : summaries.OrderBy(x => x.CreatedAt).ThenBy(x => x.UserKey), + ? summaries.OrderByDescending(x => x.PrimaryPhone) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.PrimaryPhone) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey), _ => query.Descending - ? summaries.OrderByDescending(x => x.CreatedAt).ThenBy(x => x.UserKey) - : summaries.OrderBy(x => x.CreatedAt).ThenBy(x => x.UserKey) + ? summaries.OrderByDescending(x => x.CreatedAt) + .ThenBy(x => x.UserKey) + : summaries.OrderBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey) }; - var offset = - ((long)normalized.PageNumber - 1) * - normalized.PageSize; + var offset = ((long)normalized.PageNumber - 1) * normalized.PageSize; if (offset > int.MaxValue) { diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs index b737c21c..3fe9285b 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserSummaryQueryStore.cs @@ -114,28 +114,44 @@ public async Task> QueryAsync(UserQuery query, Cancella ).ToList(); } - IEnumerable ordered = query.SortBy switch + + IOrderedEnumerable OrderByText(Func selector) + { + return query.Descending + ? summaries.OrderByDescending(selector, StringComparer.OrdinalIgnoreCase) + : summaries.OrderBy(selector, StringComparer.OrdinalIgnoreCase); + } + + IOrderedEnumerable OrderByCreatedAt() + { + return query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt) + : summaries.OrderBy(x => x.CreatedAt); + } + + IOrderedEnumerable ordered = query.SortBy switch { nameof(UserSummary.DisplayName) => - query.Descending - ? summaries - .OrderByDescending(x => x.DisplayName) - .ThenBy(x => x.UserKey.Value) - : summaries - .OrderBy(x => x.DisplayName) - .ThenBy(x => x.UserKey.Value), - - nameof(UserSummary.CreatedAt) => - query.Descending - ? summaries - .OrderByDescending(x => x.CreatedAt) - .ThenBy(x => x.UserKey.Value) - : summaries - .OrderBy(x => x.CreatedAt) - .ThenBy(x => x.UserKey.Value), - - _ => summaries - .OrderBy(x => x.CreatedAt) + OrderByText(x => x.DisplayName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.UserName) => + OrderByText(x => x.UserName) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.PrimaryEmail) => + OrderByText(x => x.PrimaryEmail) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + nameof(UserSummary.PrimaryPhone) => + OrderByText(x => x.PrimaryPhone) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.UserKey.Value), + + _ => OrderByCreatedAt() .ThenBy(x => x.UserKey.Value) }; diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs index 65d8da2a..52a9e2a3 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -92,8 +92,13 @@ public async Task ChangeStatusSelfAsync(HttpContext ctx) resource: "users", resourceId: flow?.UserKey?.Value); - await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + + return result.IsSuccess + ? Results.Ok(result) + : Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + detail: result.FailureReason); } public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx) @@ -110,8 +115,13 @@ public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext c resource: "users", resourceId: userKey.Value); - await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + + return result.IsSuccess + ? Results.Ok(result) + : Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + detail: result.FailureReason); } public async Task GetMeAsync(HttpContext ctx) @@ -220,8 +230,9 @@ public async Task DeleteAsync(UserKey userKey, HttpContext ctx) ["deleteMode"] = request.Mode }); - await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); - return Results.Ok(); + var result = await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); } public async Task CreateProfileSelfAsync(HttpContext ctx) diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs index 30a6dcb4..d9938222 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -11,7 +11,7 @@ public interface IUserApplicationService Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); - Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); Task CreateProfileAsync(AccessContext context, CreateProfileRequest request, CancellationToken ct = default); Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); @@ -37,5 +37,5 @@ public interface IUserApplicationService Task CheckIdentifierAvailabilityAsync(AccessContext context, CheckUserIdentifierAvailabilityRequest request, CancellationToken ct = default); Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); - Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); + Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 902d61d8..b46ba8ea 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -173,41 +173,56 @@ await identifierStore.AddAsync( return await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) + + public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var newStatus = request switch { ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), ChangeUserStatusAdminRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), + _ => throw new InvalidOperationException("invalid_request") }; var targetUserKey = context.GetTargetUserKey(); var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var current = await lifecycleStore.GetAsync(userLifecycleKey, innerCt); - var now = _clock.UtcNow; if (current is null) throw new UAuthNotFoundException("user_not_found"); if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) + { throw new UAuthConflictException("self_transition_not_allowed"); + } - if (!context.IsSelfAction) + if (!context.IsSelfAction && newStatus is UserStatus.SelfSuspended) { - if (newStatus is UserStatus.SelfSuspended) - throw new UAuthConflictException("admin_cannot_set_self_status"); + throw new UAuthConflictException("admin_cannot_set_self_status"); } + + var previousStatus = current.Status; + + // NoChange: no persistence operation is required. + if (previousStatus == newStatus) + return UserStatusChangeResult.NoChange(previousStatus); + + var now = _clock.UtcNow; + var newEntity = current.ChangeStatus(now, newStatus); await lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); + + return UserStatusChangeResult.Success(previousStatus, newStatus); }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); + return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = default) { var command = new AccessCommand(async innerCt => @@ -248,9 +263,10 @@ public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = de await _accessOrchestrator.ExecuteAsync(context, command, ct); } - public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) { - var command = new AccessCommand(async innerCt => + var command = new AccessCommand(async innerCt => { var targetUserKey = context.GetTargetUserKey(); var now = _clock.UtcNow; @@ -263,14 +279,15 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque var profileStore = _profileStoreFactory.Create(context.ResourceTenant); var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + await lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); await identifierStore.DeleteByUserAsync(targetUserKey, request.Mode, now, innerCt); - var profiles = await profileStore.GetAllProfilesByUserAsync(targetUserKey, innerCt); foreach (var profile in profiles) { var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey); + await profileStore.DeleteAsync(key, profile.Version, request.Mode, now, innerCt); } @@ -280,12 +297,16 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque } var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(targetUserKey, now, innerCt); + + return UserDeleteResult.Success(request.Mode); }); - await _accessOrchestrator.ExecuteAsync(context, command, ct); + return await _accessOrchestrator.ExecuteAsync(context, command, ct); } + #endregion diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs index 89403219..b71421cd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CredentialManagementTests.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -13,6 +14,7 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Integration; @@ -162,10 +164,21 @@ await factory.GrantPermissionsAsync( $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", target.Identifier); - response.StatusCode.Should().Be(HttpStatusCode.NoContent); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content + .ReadFromJsonAsync(); + + result.Should().NotBeNull(); + result!.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); notifier.Notifications.Should() .ContainSingle(x => x.UserKey == target.UserKey); + + var body = await response.Content.ReadAsStringAsync(); + + body.Should().NotContain( + notifier.Notifications.Single().Token); } [Fact] @@ -242,7 +255,12 @@ await factory.GrantPermissionsAsync( $"{url}/begin", target.Identifier); - begin.StatusCode.Should().Be(HttpStatusCode.NoContent); + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var beginResult = await begin.Content + .ReadFromJsonAsync(); + + beginResult.Should().NotBeNull(); var token = notifier.Notifications .Single(x => x.UserKey == target.UserKey) @@ -257,7 +275,13 @@ await factory.GrantPermissionsAsync( token, newPassword); - complete.StatusCode.Should().Be(HttpStatusCode.NoContent); + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + var completeResult = await complete.Content + .ReadFromJsonAsync(); + + completeResult.Should().NotBeNull(); + completeResult!.IsSuccess.Should().BeTrue(); await AssertPasswordAsync(factory, target, newPassword); await AssertPasswordRejectedAsync(factory, target, target.Secret); @@ -400,6 +424,532 @@ await AssertPasswordAsync( existingUser.Secret); } + [Fact] + public async Task AnonymousRecovery_DefaultPolicy_RevokesAllExistingChains() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + // Two independent clients with different X-UDID values. + using var firstClient = CreateClient(factory); + using var secondClient = CreateClient(factory); + + await AuthenticateAsync(firstClient, user); + await AuthenticateAsync(secondClient, user); + + // Verify both chains exist and are active before recovery. + using (var scope = factory.Services.CreateScope()) + { + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + var chains = await store.GetChainsByUserAsync(user.UserKey); + + chains.Should().HaveCount(2); + chains.Should().OnlyContain(c => !c.IsRevoked); + } + + // Recovery must not rely on either authenticated client. + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == user.UserKey) + .Token; + + var newPassword = $"Recovered-{Guid.NewGuid():N}!"; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + newPassword); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + // Recovery must actually change the password. + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + // Both previously active chains must now be revoked. + using (var scope = factory.Services.CreateScope()) + { + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + var chains = await store.GetChainsByUserAsync(user.UserKey); + + chains.Should().HaveCount(2); + chains.Should().OnlyContain(c => c.IsRevoked); + } + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false)] + [InlineData(CredentialSessionBehavior.RevokeAll, true)] + public async Task Recovery_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool shouldRevoke) + { + var notifier = new TestResetNotifier(); + + using var factory = AuthServerFactory.Create( + options => + { + options.CredentialSessionPolicy.OnRecovery = behavior; + }, + services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var firstClient = CreateClient(factory); + using var secondClient = CreateClient(factory); + + await AuthenticateAsync(firstClient, user); + await AuthenticateAsync(secondClient, user); + + var before = await GetChainsAsync(factory, user.UserKey); + + before.Should().HaveCount(2); + before.Should().OnlyContain(c => !c.IsRevoked); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == user.UserKey) + .Token; + + var newPassword = $"Recovered-{Guid.NewGuid():N}!"; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + newPassword); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + var after = await GetChainsAsync(factory, user.UserKey); + + after.Should().HaveCount(2); + + foreach (var chain in after) + { + chain.IsRevoked.Should().Be( + shouldRevoke, + $"OnRecovery = {behavior} must be respected"); + } + } + + [Fact] + public async Task Recovery_RevokeAll_ShouldNotAffectOtherUsers() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + + using var targetClient = CreateClient(factory); + using var otherClient = CreateClient(factory); + + await AuthenticateAsync(targetClient, target); + await AuthenticateAsync(otherClient, other); + + var targetBefore = await GetChainsAsync(factory, target.UserKey); + var otherBefore = await GetChainsAsync(factory, other.UserKey); + + targetBefore.Should().ContainSingle(c => !c.IsRevoked); + otherBefore.Should().ContainSingle(c => !c.IsRevoked); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + target.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications + .Single(x => x.UserKey == target.UserKey) + .Token; + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + target.Identifier, + token, + $"Recovered-{Guid.NewGuid():N}!"); + + complete.StatusCode.Should().Be(HttpStatusCode.OK); + + var targetAfter = await GetChainsAsync(factory, target.UserKey); + var otherAfter = await GetChainsAsync(factory, other.UserKey); + + targetAfter.Should().ContainSingle(c => c.IsRevoked); + otherAfter.Should().ContainSingle(c => !c.IsRevoked); + } + + [Fact] + public async Task Recovery_InvalidToken_ShouldNotRevokeExistingChains() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var authenticatedClient = CreateClient(factory); + await AuthenticateAsync(authenticatedClient, user); + + using var recoveryClient = CreateClient(factory); + + var begin = await BeginResetAsync( + recoveryClient, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var complete = await CompleteResetAsync( + recoveryClient, + "/auth/me/credentials/reset/complete", + user.Identifier, + "invalid-token", + $"Changed-{Guid.NewGuid():N}!"); + + // Anonymous endpoint may return a generic success + // to prevent account enumeration. + complete.IsSuccessStatusCode.Should().BeTrue(); + + var chains = await GetChainsAsync(factory, user.UserKey); + + chains.Should().ContainSingle(c => !c.IsRevoked); + + await AssertPasswordAsync(factory, user, user.Secret); + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false, false)] + [InlineData(CredentialSessionBehavior.PreserveCurrent, false, true)] + [InlineData(CredentialSessionBehavior.RevokeAll, true, true)] + public async Task SelfChange_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool revokeCurrent, bool revokeOther) + { + using var factory = AuthServerFactory.Create(options => + { + options.CredentialSessionPolicy.OnSelfChange = behavior; + }); + + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var currentClient = CreateClient(factory); + using var otherClient = CreateClient(factory); + + await AuthenticateAsync(currentClient, user); + + var firstChains = await GetChainsAsync(factory, user.UserKey); + var currentChain = firstChains.Should().ContainSingle().Subject; + + await AuthenticateAsync(otherClient, user); + + var before = await GetChainsAsync(factory, user.UserKey); + before.Should().HaveCount(2); + + var otherChain = before.Single( + c => c.ChainId != currentChain.ChainId); + + before.Should().OnlyContain(c => !c.IsRevoked); + + var newPassword = $"Changed-{Guid.NewGuid():N}!"; + + var response = await currentClient.PostAsJsonAsync( + "/auth/me/credentials/change", + new ChangeCredentialRequest + { + CurrentSecret = user.Secret, + NewSecret = newPassword + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, user, newPassword); + await AssertPasswordRejectedAsync(factory, user, user.Secret); + + var after = await GetChainsAsync(factory, user.UserKey); + + after.Single(c => c.ChainId == currentChain.ChainId) + .IsRevoked.Should().Be(revokeCurrent); + + after.Single(c => c.ChainId == otherChain.ChainId) + .IsRevoked.Should().Be(revokeOther); + } + + [Theory] + [InlineData(CredentialSessionBehavior.PreserveAll, false)] + [InlineData(CredentialSessionBehavior.RevokeAll, true)] + public async Task AdminChange_ShouldRespectConfiguredSessionPolicy(CredentialSessionBehavior behavior, bool shouldRevoke) + { + using var factory = AuthServerFactory.Create(options => + { + options.CredentialSessionPolicy.OnAdminChange = behavior; + }); + + factory.Clock.Reset(); + + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + + await factory.GrantPermissionsAsync( + admin.UserKey, + [UAuthActions.Credentials.ChangeAdmin]); + + using var adminClient = CreateClient(factory); + using var targetClient1 = CreateClient(factory); + using var targetClient2 = CreateClient(factory); + + await AuthenticateAsync(adminClient, admin); + await AuthenticateAsync(targetClient1, target); + await AuthenticateAsync(targetClient2, target); + + var before = await GetChainsAsync(factory, target.UserKey); + before.Should().HaveCount(2); + before.Should().OnlyContain(c => !c.IsRevoked); + + var newPassword = $"AdminChanged-{Guid.NewGuid():N}!"; + + var response = await adminClient.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/change", + new ChangeCredentialRequest + { + NewSecret = newPassword + }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + await AssertPasswordAsync(factory, target, newPassword); + await AssertPasswordRejectedAsync(factory, target, target.Secret); + + var after = await GetChainsAsync(factory, target.UserKey); + + after.Should().HaveCount(2); + after.Should().OnlyContain(c => c.IsRevoked == shouldRevoke); + + var adminChains = await GetChainsAsync(factory, admin.UserKey); + adminChains.Should().ContainSingle(c => !c.IsRevoked); + } + + [Fact] + public async Task AnonymousBeginReset_AlreadyActive_ShouldNotRevealResetState() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var first = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + first.StatusCode.Should().Be(HttpStatusCode.OK); + + var notificationCount = notifier.Notifications.Count; + + var active = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + var unknown = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + $"unknown-{Guid.NewGuid():N}"); + + active.StatusCode.Should().Be(unknown.StatusCode); + + (await active.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + notifier.Notifications.Should().HaveCount(notificationCount); + } + + [Fact] + public async Task AnonymousCompleteReset_ExpiredToken_ShouldNotRevealResetState() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var begin = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var notification = notifier.Notifications.Single(); + + factory.Clock.Advance( + notification.ExpiresAt - factory.Clock.UtcNow + + TimeSpan.FromSeconds(1)); + + var expired = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + notification.Token, + "Changed-Password-123!"); + + var unknown = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + $"unknown-{Guid.NewGuid():N}", + notification.Token, + "Changed-Password-123!"); + + expired.StatusCode.Should().Be(unknown.StatusCode); + + (await expired.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + await AssertPasswordAsync(factory, user, user.Secret); + } + + [Fact] + public async Task AnonymousBeginReset_InvalidCodeType_ShouldRejectConsistently() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + async Task SendAsync(string identifier) + { + return await client.PostAsJsonAsync( + "/auth/me/credentials/reset/begin", + new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = (ResetCodeType)999 + }); + } + + var existing = await SendAsync(user.Identifier); + + var unknown = await SendAsync( + $"unknown-{Guid.NewGuid():N}"); + + existing.StatusCode.Should().Be(HttpStatusCode.BadRequest); + unknown.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + using var existingJson = JsonDocument.Parse(await existing.Content.ReadAsStringAsync()); + using var unknownJson = JsonDocument.Parse(await unknown.Content.ReadAsStringAsync()); + + existingJson.RootElement.GetProperty("status").GetInt32() + .Should().Be(unknownJson.RootElement.GetProperty("status").GetInt32()); + + existingJson.RootElement.GetProperty("title").GetString() + .Should().Be(unknownJson.RootElement.GetProperty("title").GetString()); + + existingJson.RootElement.GetProperty("type").GetString() + .Should().Be(unknownJson.RootElement.GetProperty("type").GetString()); + + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task AnonymousCompleteReset_SamePassword_ShouldNotRevealCredentialState() + { + var notifier = new TestResetNotifier(); + + using var factory = CreateFactory(notifier); + factory.Clock.Reset(); + + var user = await factory.CreateLoginUserAsync(); + + using var client = CreateClient(factory); + + var begin = await BeginResetAsync( + client, + "/auth/me/credentials/reset/begin", + user.Identifier); + + begin.StatusCode.Should().Be(HttpStatusCode.OK); + + var token = notifier.Notifications.Single().Token; + + var samePassword = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + user.Identifier, + token, + user.Secret); + + var unknown = await CompleteResetAsync( + client, + "/auth/me/credentials/reset/complete", + $"unknown-{Guid.NewGuid():N}", + token, + user.Secret); + + samePassword.StatusCode.Should().Be(unknown.StatusCode); + + (await samePassword.Content.ReadAsStringAsync()) + .Should().Be(await unknown.Content.ReadAsStringAsync()); + + await AssertPasswordAsync(factory, user, user.Secret); + } + private static HttpClient CreateClient(AuthServerFactory factory) @@ -540,4 +1090,16 @@ private static async Task GetPasswordCredentialAsync(AuthSer .OfType() .Single(); } + + private static async Task> GetChainsAsync(AuthServerFactory factory, UserKey userKey) + { + using var scope = factory.Services.CreateScope(); + + var sessionFactory = scope.ServiceProvider + .GetRequiredService(); + + var store = sessionFactory.Create(TenantKeys.Single); + + return await store.GetChainsByUserAsync(userKey); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs new file mode 100644 index 00000000..1e353d3d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/SdkEnpointContractTests.cs @@ -0,0 +1,260 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +/// +/// Real TestServer HTTP response contracts. Does not exercise the SDK request client/mapper. +/// +public sealed class SdkEndpointContractTests +{ + [Fact] + public async Task ChangeMyStatus_ShouldReturnTypedJson_NotEmpty200() + { + using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(); + using var client = CreateClient(factory); + await AuthenticateAsync(client, user); + using var response = await client.PostAsJsonAsync("/auth/me/status", + new ChangeUserStatusSelfRequest { NewStatus = SelfAssignableUserStatus.SelfSuspended }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Active); + result.CurrentStatus.Should().Be(UserStatus.SelfSuspended); + } + + [Fact] + public async Task ChangeUserStatus_ShouldReturnTypedJson_NotEmpty200() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/status", + new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Active); + result.CurrentStatus.Should().Be(UserStatus.Suspended); + } + + [Fact] + public async Task ChangeUserStatus_AlreadyInRequestedStatus_ShouldReturnTypedJson() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.ChangeStatusAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + var url = $"/auth/admin/users/{target.UserKey.Value}/status"; + var request = new ChangeUserStatusAdminRequest { NewStatus = AdminAssignableUserStatus.Suspended }; + using var first = await client.PostAsJsonAsync(url, request); + (await AssertTypedSuccessAsync(first)).IsSuccess.Should().BeTrue(); + using var second = await client.PostAsJsonAsync(url, request); + var result = await AssertTypedSuccessAsync(second); + result.IsSuccess.Should().BeTrue(); + result.PreviousStatus.Should().Be(UserStatus.Suspended); + result.CurrentStatus.Should().Be(UserStatus.Suspended); + } + + [Fact] + public async Task DeleteUser_ShouldReturnTypedJson_WithRequestedMode() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Users.DeleteAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/delete", + new DeleteUserRequest { Mode = DeleteMode.Soft }); + var result = await AssertTypedSuccessAsync(response); + result.IsSuccess.Should().BeTrue(); + result.Mode.Should().Be(DeleteMode.Soft); + } + + [Fact] + public async Task AdminBeginReset_ShouldReturnTypedJson_WithoutProof() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(target)); + var result = await AssertTypedSuccessAsync(response); + result.ExpiresAt.Should().BeAfter(factory.Clock.UtcNow); + var body = await response.Content.ReadAsStringAsync(); + var notification = notifier.Notifications.Should().ContainSingle(x => x.UserKey == target.UserKey).Subject; + body.Should().NotContain(notification.Token, "reset proof must only be sent through the notifier"); + } + + [Fact] + public async Task AdminCompleteReset_ShouldReturnTypedJson_AndChangeCredential() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, + [UAuthActions.Credentials.BeginResetAdmin, UAuthActions.Credentials.CompleteResetAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + var url = $"/auth/admin/users/{target.UserKey.Value}/credentials/reset"; + using var begin = await client.PostAsJsonAsync($"{url}/begin", BeginResetRequest(target)); + await AssertTypedSuccessAsync(begin); + var token = notifier.Notifications.Single(x => x.UserKey == target.UserKey).Token; + var newSecret = $"Changed-{Guid.NewGuid():N}!"; + using var complete = await client.PostAsJsonAsync($"{url}/complete", + new CompleteResetCredentialRequest + { + Identifier = target.Identifier, + CredentialType = CredentialType.Password, + ResetToken = token, + NewSecret = newSecret + }); + var result = await AssertTypedSuccessAsync(complete); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task AdminBeginReset_TargetMismatch_ShouldReturn400_AndNotNotify() + { + var notifier = new CapturingResetNotifier(); + using var factory = CreateResetFactory(notifier); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + var other = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.BeginResetAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/reset/begin", BeginResetRequest(other)); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + notifier.Notifications.Should().BeEmpty(); + } + + [Fact] + public async Task RevokeMy_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var user = await factory.CreateLoginUserAsync(); + using var client = CreateClient(factory); + await AuthenticateAsync(client, user); + using var response = await client.PostAsJsonAsync("/auth/me/credentials/revoke", + new RevokeCredentialRequest { Id = Guid.NewGuid() }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task RevokeAdmin_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.RevokeAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/revoke", + new RevokeCredentialRequest { Id = Guid.NewGuid() }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task DeleteCredentialAdmin_UnknownCredential_ShouldNotReturnSuccessful204() + { + using var factory = new AuthServerFactory(); + var admin = await factory.CreateLoginUserAsync(); + var target = await factory.CreateLoginUserAsync(); + await factory.GrantPermissionsAsync(admin.UserKey, [UAuthActions.Credentials.DeleteAdmin]); + using var client = CreateClient(factory); + await AuthenticateAsync(client, admin); + using var response = await client.PostAsJsonAsync( + $"/auth/admin/users/{target.UserKey.Value}/credentials/delete", + new DeleteCredentialRequest { Id = Guid.NewGuid(), Mode = DeleteMode.Soft }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private static BeginResetCredentialRequest BeginResetRequest(IntegrationTestUser user) => new() + { + Identifier = user.Identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }; + + private static AuthServerFactory CreateResetFactory(CapturingResetNotifier notifier) => + AuthServerFactory.CreateWithServices(services => + { + services.RemoveAll(); + services.AddSingleton(notifier); + }); + + private static async Task AssertTypedSuccessAsync(HttpResponseMessage response) + { + response.StatusCode.Should().Be(HttpStatusCode.OK, + "FromJson requires a successful response with JSON content"); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + var json = await response.Content.ReadAsStringAsync(); + json.Should().NotBeNullOrWhiteSpace(); + var value = await response.Content.ReadFromJsonAsync(); + value.Should().NotBeNull(); + return value!; + } + + private sealed class CapturingResetNotifier : ICredentialResetNotifier + { + private readonly ConcurrentQueue _notifications = new(); + public IReadOnlyList Notifications => _notifications.ToArray(); + public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default) + { + _notifications.Enqueue(notification); + return Task.CompletedTask; + } + } + + private static HttpClient CreateClient(AuthServerFactory factory) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", $"contract-{Guid.NewGuid():N}"); + return client; + } + + private static async Task AuthenticateAsync(HttpClient client, IntegrationTestUser user) + { + using var response = await client.PostAsJsonAsync("/auth/login", new + { + identifier = user.Identifier, + secret = user.Secret + }); + 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)).Split(';', 2)[0]; + client.DefaultRequestHeaders.Add("Cookie", cookie); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs index 9f9ab6d9..51a26819 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs @@ -1,8 +1,10 @@ using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; using Moq; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -65,19 +67,43 @@ public async Task AddUser_Should_Call_Admin_Endpoint() Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/add", It.IsAny()), Times.Once); } + [Fact] public async Task DeleteUser_Should_Call_Delete_Endpoint() { - Request.Setup(x => x.SendFormAsync(It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + var userKey = UserKey.FromString("user-1"); + + var request = new DeleteCredentialRequest + { + Id = Guid.NewGuid(), + Mode = DeleteMode.Hard + }; + + Request + .Setup(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/credentials/delete", + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 204 + }); var client = CreateCredentialClient(); - var userKey = UserKey.FromString("user-1"); - await client.Credentials.DeleteUserAsync(userKey, new DeleteCredentialRequest()); - Request.Verify(x => - x.SendFormAsync($"/auth/admin/users/{userKey.Value}/credentials/delete", - It.IsAny>(), - It.IsAny()), + + var result = await client.Credentials.DeleteUserAsync( + userKey, + request); + + result.IsSuccess.Should().BeTrue(); + + Request.Verify(x => x.SendJsonAsync( + $"/auth/admin/users/{userKey.Value}/credentials/delete", + It.Is(r => + r.Id == request.Id && + r.Mode == request.Mode), + It.IsAny()), Times.Once); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs index e7c280b1..47d17807 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -1,8 +1,7 @@ -using System.Text; -using System.Text.Json; -using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; using CodeBeam.UltimateAuth.Server.Auth; @@ -11,6 +10,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Moq; +using System.Text; +using System.Text.Json; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -550,6 +551,7 @@ public async Task DeleteAdminAsync_WhenAuthenticated_ForwardsDeleteModeAndReturn // Begin Reset - Admin // ========================================================= + [Fact] public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() { @@ -562,9 +564,6 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc f.SetJsonBody(new BeginResetCredentialRequest { - // Important: - // Admin endpoint's authorization target is userKey, - // not this identifier. Identifier = "alice@example.com", CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token @@ -575,24 +574,40 @@ public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourc target.Value, access); + var expected = new BeginCredentialResetResult(); + f.Credentials .Setup(x => x.BeginResetAsync( access, It.IsAny(), f.HttpContext.RequestAborted)) - .ReturnsAsync(new BeginCredentialResetResult()); + .ReturnsAsync(expected); var result = await f.Sut.BeginResetAdminAsync( target, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.Credentials.Verify(x => x.BeginResetAsync( + access, + It.Is(r => + r.Identifier == "alice@example.com" && + r.CredentialType == CredentialType.Password && + r.ResetCodeType == ResetCodeType.Token), + f.HttpContext.RequestAborted), + Times.Once); } // ========================================================= // Complete Reset - Admin // ========================================================= + [Fact] public async Task CompleteResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() { @@ -616,18 +631,33 @@ public async Task CompleteResetAdminAsync_WhenAuthenticated_UsesTargetUserAsReso target.Value, access); + var expected = CredentialActionResult.Success(); + f.Credentials .Setup(x => x.CompleteResetAsync( access, It.IsAny(), f.HttpContext.RequestAborted)) - .ReturnsAsync(CredentialActionResult.Success()); + .ReturnsAsync(expected); var result = await f.Sut.CompleteResetAdminAsync( target, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.Credentials.Verify(x => x.CompleteResetAsync( + access, + It.Is(r => + r.Identifier == "alice@example.com" && + r.ResetToken == "token" && + r.NewSecret == "new-password"), + f.HttpContext.RequestAborted), + Times.Once); } // ========================================================= diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs index c38605ea..891e3981 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -5,6 +5,7 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Credentials; using CodeBeam.UltimateAuth.Credentials.Contracts; using CodeBeam.UltimateAuth.Credentials.Reference; @@ -339,6 +340,8 @@ public async Task ChangeSecretAsync_Self_WhenValid_SavesAndRevokesOtherChains() It.IsAny(), It.IsAny()), Times.Never); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.SelfChange), Times.Once); } [Fact] @@ -376,6 +379,8 @@ public async Task ChangeSecretAsync_SelfWithoutActorChain_RevokesAllChains() Now, It.IsAny()), Times.Once); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.SelfChange), Times.Once); } [Fact] @@ -447,6 +452,8 @@ public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_ Now, It.IsAny()), Times.Once); + + f.CredentialSessionPolicy.Verify(x => x.Resolve(CredentialChangeReason.AdminChange), Times.Once); } // --------------------------------------------------------- @@ -972,6 +979,9 @@ private sealed class Fixture public Mock SessionStore { get; } = new(MockBehavior.Strict); + public Mock CredentialSessionPolicy { get; } + = new(MockBehavior.Strict); + public Mock Clock { get; } = new(MockBehavior.Strict); @@ -1048,6 +1058,18 @@ public Fixture() .Setup(x => x.Create(It.IsAny())) .Returns(SessionStore.Object); + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.SelfChange)) + .Returns(CredentialSessionBehavior.PreserveCurrent); + + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.AdminChange)) + .Returns(CredentialSessionBehavior.RevokeAll); + + CredentialSessionPolicy + .Setup(x => x.Resolve(CredentialChangeReason.Recovery)) + .Returns(CredentialSessionBehavior.RevokeAll); + var options = Options.Create( TestServerOptions.Default()); @@ -1062,6 +1084,7 @@ public Fixture() IdentifierResolver.Object, SessionStoreFactory.Object, ResetNotifier.Object, + CredentialSessionPolicy.Object, options, Clock.Object); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs index 6b2190b9..0c8db7da 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -110,7 +110,7 @@ public async Task Reset_password_with_valid_token_should_succeed() } [Fact] - public async Task Reset_password_with_same_password_should_fail() + public async Task Reset_password_with_same_password_should_succeed_without_changing_password() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); @@ -118,12 +118,19 @@ public async Task Reset_password_with_same_password_should_fail() var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "admin")); + var result = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "admin")); + + result.IsSuccess.Should().BeTrue(); - await act.Should().ThrowAsync(); + // The reset token must be single-use even when + // the new password matches the existing password. + var secondResult = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "different-password")); + + secondResult.IsSuccess.Should().BeTrue(); } [Fact] @@ -194,32 +201,34 @@ public async Task Reset_token_should_be_single_use() } [Fact] - public async Task Reset_token_should_fail_if_expired() + public async Task Reset_token_should_not_change_password_if_expired() { var (runtime, notifier) = CreateRuntime(); var service = runtime.GetCredentialManagementService(); + var userKey = await GetAdminUserKeyAsync(runtime); + var token = await BeginAndCaptureAsync( runtime, notifier, ResetCodeType.Token); var notification = notifier.Notifications.Single(); - // Advance past the actual configured expiry. runtime.Clock.Advance( notification.ExpiresAt - runtime.Clock.UtcNow + TimeSpan.FromSeconds(1)); - Func act = async () => - await service.CompleteResetAsync( - CompleteContext(), - CompleteRequest(token, "newpass123")); + var result = await service.CompleteResetAsync( + CompleteContext(), + CompleteRequest(token, "newpass123")); - await act.Should().ThrowAsync(); + // Generic anonymous response, not proof of a password change. + result.IsSuccess.Should().BeTrue(); + + await AssertPasswordAsync(runtime, userKey, "admin"); + await AssertPasswordRejectedAsync(runtime, userKey, "newpass123"); } - private static ( - TestAuthRuntime Runtime, - RecordingCredentialResetNotifier Notifier) + private static (TestAuthRuntime Runtime, RecordingCredentialResetNotifier Notifier) CreateRuntime(int maxAttempts = 3) { var notifier = new RecordingCredentialResetNotifier(); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs index 340e9dec..5868c2d6 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -1844,6 +1844,24 @@ private static Fixture CreateFixture(params IUserLifecycleIntegration[] integrat .Returns, CancellationToken>( (_, command, ct) => command.ExecuteAsync(ct)); + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + atomic .Setup(x => x.ExecuteAsync( It.IsAny>>(), diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs index 497cae21..69e65095 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -290,17 +290,24 @@ public async Task ChangeStatusSelfAsync_UsesSelfActionAndActorUserKey() default)) .ReturnsAsync(accessContext); + var expected = UserStatusChangeResult.Success( + UserStatus.Active, + UserStatus.SelfSuspended); + f.Users .Setup(x => x.ChangeUserStatusAsync( accessContext, It.IsAny(), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); - var result = - await f.Sut.ChangeStatusSelfAsync(f.HttpContext); + var result = await f.Sut.ChangeStatusSelfAsync(f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.ChangeUserStatusAsync( accessContext, @@ -335,7 +342,6 @@ public async Task ChangeStatusAdminAsync_WhenUnauthenticated_ReturnsUnauthorized public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() { var f = CreateFixture(); - var targetUser = UserKey.New(); var request = new ChangeUserStatusAdminRequest @@ -345,8 +351,7 @@ public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() SetJsonBody(f.HttpContext, request); - var accessContext = - CreateAccessContext(f, targetUser); + var accessContext = CreateAccessContext(f, targetUser); f.AccessContextFactory .Setup(x => x.CreateAsync( @@ -358,18 +363,26 @@ public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() default)) .ReturnsAsync(accessContext); + var expected = UserStatusChangeResult.Success( + UserStatus.Active, + UserStatus.Suspended); + f.Users .Setup(x => x.ChangeUserStatusAsync( accessContext, It.IsAny(), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); var result = await f.Sut.ChangeStatusAdminAsync( targetUser, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.ChangeUserStatusAsync( accessContext, @@ -457,7 +470,7 @@ public async Task DeleteAsync_UsesDeleteAdminAction_TargetUser_AndDeleteModeAttr SetJsonBody(f.HttpContext, request); - AccessContext? capturedContext = null; + var accessContext = CreateAccessContext(f, targetUser); f.AccessContextFactory .Setup(x => x.CreateAsync( @@ -469,38 +482,36 @@ public async Task DeleteAsync_UsesDeleteAdminAction_TargetUser_AndDeleteModeAttr a.ContainsKey("deleteMode") && (DeleteMode)a["deleteMode"] == DeleteMode.Hard), default)) - .ReturnsAsync((AuthFlowContext _, - string _, - string _, - string? _, - IDictionary? _, - CancellationToken _) => - { - capturedContext = CreateAccessContext(f, targetUser); - return capturedContext; - }); + .ReturnsAsync(accessContext); + + var expected = UserDeleteResult.Success(DeleteMode.Hard); f.Users .Setup(x => x.DeleteUserAsync( - It.IsAny(), - It.Is(r => r.Mode == DeleteMode.Hard), + accessContext, + It.Is( + r => r.Mode == DeleteMode.Hard), f.HttpContext.RequestAborted)) - .Returns(Task.CompletedTask); + .ReturnsAsync(expected); var result = await f.Sut.DeleteAsync( targetUser, f.HttpContext); - result.Should().BeOfType(); + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeEquivalentTo(expected); f.Users.Verify(x => x.DeleteUserAsync( - It.IsAny(), - It.Is(r => r.Mode == DeleteMode.Hard), + accessContext, + It.Is( + r => r.Mode == DeleteMode.Hard), f.HttpContext.RequestAborted), Times.Once); } - // ============================================================ // Profiles // ============================================================