>)LlQ zhtGCCS1$y1hSnJH9OupY>z$xY8E7tkXJ1F|7^PX3)eLMkR!)o3raNz;DO7hdrM+}$ zwkZw8oL+=G2JZDX<2p02&LHuEpabUEAI9VqQ<6#r{UCsMp8L@a=HjlYctm$)Q#=~g z{zg*MtG++h)A28g1~&Dl&}!9KV7^BAB z;EAbM{v2=W=4K~HmZiQ;&)%k9)n%&WyMEoWuD>_?zu1S_?DmuUFD% Y9;d|m^9lw2rfLq9CX5!TF27{mANKQ#kX5D)J`39dD+SmV zm}{-V0t47SIwZaQZ4ansQ3rLxX0m@=o!eCLP44P%ycA}Bjxn%fpjIl}Z%U}ebSG}V zQIpW3BKu@b*gURudYZYHISgzL@a`$h!*W8{KFNs04TujcHH4yhP=kv2mWqkH`9F_{ j82fyj{PmPDvv|`P*mP6TWIUqE92QbPu^fe>b*KLW_ho#0 diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs index d2186593..43b08720 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs @@ -92,11 +92,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index d9f06ffa..73d6ebd3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -161,7 +161,7 @@ private void HandleTry(IUAuthTryResult result) { if (result is TryLoginResult pkce) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs index 681ff884..f02691c1 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -25,10 +25,16 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, + NewSecret = _newPassword, Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index d26cbda0..6acf2d76 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddUltimateAuthSampleSeed(); +// Customize UltimateAuth if (builder.Environment.IsDevelopment()) { builder.Services.AddScoped
(); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs index c96497e8..8fd1ba9d 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs @@ -94,11 +94,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs index 78129102..3250a476 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -101,7 +101,7 @@ private async Task ProgrammaticLogin() private async Task HandleLoginResult(IUAuthTryResult result) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs index 43e7b541..231c8115 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs @@ -25,10 +25,16 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, + NewSecret = _newPassword, Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs index 50a8d74a..611e7141 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs @@ -94,11 +94,11 @@ private async Task SaveAsync() if (UserKey is null) { - result = await UAuthClient.Users.UpdateMeAsync(request); + result = await UAuthClient.Users.UpdateMyProfileAsync(request); } else { - result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); } if (result.IsSuccess) diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs index 2cb3f032..616df5f2 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs @@ -101,7 +101,7 @@ private async Task ProgrammaticLogin() private async Task HandleLoginResult(IUAuthTryResult result) { - if (!result.Success) + if (!result.IsSuccess) { if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) { diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs index be79c63a..401d8656 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs @@ -25,11 +25,17 @@ private async Task ResetPasswordAsync() return; } + if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier)) + { + Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error); + return; + } + var request = new CompleteResetCredentialRequest { ResetToken = _code, - NewSecret = _newPassword ?? string.Empty, - Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + NewSecret = _newPassword, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful }; var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs new file mode 100644 index 00000000..7846f35d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs @@ -0,0 +1,7 @@ +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IUAuthPaginationPolicy +{ + int DefaultPageSize { get; } + int MaxPageSize { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs index 4f3812bf..7b0e459b 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -14,5 +14,5 @@ public sealed record AuthIdentitySnapshot public DateTimeOffset? AuthenticatedAt { get; init; } public SessionState? SessionState { get; init; } public string? TimeZone { get; init; } - public UserStatus UserStatus { get; set; } + public UserStatus UserStatus { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs index 7fa62828..0ea44e81 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -27,6 +27,10 @@ public sealed class AccessContext public bool HasActor => ActorUserKey != null; public bool HasTarget => TargetUserKey != null; + public bool IsAdminContext => Action.EndsWith(".admin", StringComparison.Ordinal); + public bool IsSelfContext => Action.EndsWith(".self", StringComparison.Ordinal); + public bool IsAnonymousContext => Action.EndsWith(".anonymous", StringComparison.Ordinal); + public UserKey GetTargetUserKey() { if (TargetUserKey is not UserKey targetUserKey) diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs index fa89076e..d0b27ae9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -19,7 +19,7 @@ public static AccessDecision Allow() requiresReauthentication: false, denyReason: null); - public static AccessDecision Deny(string reason) + public static AccessDecision Deny(string? reason) => new( isAllowed: false, requiresReauthentication: false, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs index 0424ad16..d4b6799c 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record AuthContext { - public UAuthClientProfile ClientProfile { get; set; } + public UAuthClientProfile ClientProfile { get; init; } public TenantKey Tenant { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs index 1fee5de4..daeda5ba 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public interface IUAuthTryResult { - bool Success { get; } + bool IsSuccess { get; } AuthFailureReason? Reason { get; } int? RemainingAttempts { get; } DateTimeOffset? LockoutUntilUtc { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs index 1dc6aed7..4dd85c89 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -1,30 +1,29 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Contracts; public record PageRequest { public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = 250; + public int PageSize { get; init; } = 0; public string? SortBy { get; init; } public bool Descending { get; init; } - public int MaxPageSize { get; init; } = 1000; - - public PageRequest Normalize() + public PageRequest Normalize(IUAuthPaginationPolicy policy) { - var page = PageNumber <= 0 ? 1 : PageNumber; - var size = PageSize <= 0 ? 250 : PageSize; + ArgumentNullException.ThrowIfNull(policy); + + var page = Math.Max(1, PageNumber); + var size = PageSize <= 0 ? policy.DefaultPageSize : PageSize; - if (size > MaxPageSize) - size = MaxPageSize; + size = Math.Min(size, policy.MaxPageSize); - return new PageRequest + return this with { PageNumber = page, - PageSize = size, - SortBy = SortBy, - Descending = Descending, - MaxPageSize = MaxPageSize + PageSize = size }; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs index 854eafa4..bfe06bcf 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -9,7 +9,7 @@ public sealed class PagedResult public string? SortBy { get; init; } public bool Descending { get; init; } - public bool HasNext => PageNumber * PageSize < TotalCount; + public bool HasNext => (long)PageNumber * PageSize < TotalCount; public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending) { diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs index 64f2be08..6fa7a402 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -1,10 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ExternalLoginRequest -{ - public required string Provider { get; init; } - public required string ExternalToken { get; init; } - public required DeviceContext Device { get; init; } -} +//public sealed record ExternalLoginRequest +//{ +// public required string Provider { get; init; } +// public required string ExternalToken { get; init; } +// public required DeviceContext Device { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs index ae45a071..5ee258d0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -6,7 +6,7 @@ public sealed record LoginRequest { public string Identifier { get; init; } = default!; public string Secret { get; init; } = default!; - public CredentialType Factor { get; init; } = CredentialType.Password; + public CredentialType CredentialType { get; init; } = CredentialType.Password; public IReadOnlyDictionary ? Metadata { get; init; } /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs index 95802a57..0f992517 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -2,7 +2,8 @@ public enum LoginStatus { - Success = 0, - RequiresContinuation = 10, - Failed = 20 + None = 0, + Success = 10, + RequiresContinuation = 20, + Failed = 30 } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs index 9ffe9ab8..d8fae096 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -1,9 +1,9 @@ -using CodeBeam.UltimateAuth.Core.Domain; +//using CodeBeam.UltimateAuth.Core.Domain; -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ReauthRequest -{ - public AuthSessionId SessionId { get; init; } - public required string Secret { get; init; } -} +//public sealed record ReauthRequest +//{ +// public AuthSessionId SessionId { get; init; } +// public required string Secret { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs index a047ff1d..45df39b7 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -1,6 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record ReauthResult -{ - public bool Success { get; init; } -} +//public sealed record ReauthResult +//{ +// public bool Success { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs index f91c14ab..ce08a546 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryLoginResult : IUAuthTryResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs index 052ef9f6..fc3cb8b2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -4,12 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record LogoutAllRequest { - /// public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + ///- /// The current session initiating the logout-all operation. - /// Used to resolve the active chain when ExceptCurrent is true. - /// - public AuthSessionId? CurrentSessionId { get; init; } - ////// If true, the current session will NOT be revoked. /// diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs index 00cd98ba..c34c89d4 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs @@ -2,5 +2,5 @@ public sealed record LogoutResponse { - public bool Success { get; init; } + public bool IsSuccess { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs index 536ed568..7ad8bab9 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -1,6 +1,6 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record BeginMfaRequest -{ - public required string MfaToken { get; init; } -} +//public sealed record BeginMfaRequest +//{ +// public required string MfaToken { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs index 7aa5d7f0..b99132d5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -1,7 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record CompleteMfaRequest -{ - public required string ChallengeId { get; init; } - public required string Code { get; init; } -} +//public sealed record CompleteMfaRequest +//{ +// public required string ChallengeId { get; init; } +// public required string Code { get; init; } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs index c0a4aad6..9cd945ee 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -1,7 +1,7 @@ -namespace CodeBeam.UltimateAuth.Core.Contracts; +//namespace CodeBeam.UltimateAuth.Core.Contracts; -public sealed record MfaChallengeResult -{ - public required string ChallengeId { get; init; } - public required string Method { get; init; } // totp, sms, email etc. -} +//public sealed record MfaChallengeResult +//{ +// public required string ChallengeId { get; init; } +// public required string Method { get; init; } // totp, sms, email etc. +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs index bc16b023..2b584d58 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record PkceCompleteResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? FailureReason { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs index aef8a634..6efd7351 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryPkceLoginResult : IUAuthTryResult { - public bool Success { get; init; } + public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } public DateTimeOffset? LockoutUntilUtc { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs index 59b18af1..68cb5656 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -4,7 +4,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed class RefreshFlowResult { - public bool Succeeded { get; init; } + public bool IsSuccess => + Outcome is RefreshOutcome.Success + or RefreshOutcome.NoOp + or RefreshOutcome.Touched + or RefreshOutcome.Rotated; + public RefreshOutcome Outcome { get; init; } public AuthSessionId? SessionId { get; init; } @@ -15,7 +20,6 @@ public static RefreshFlowResult ReauthRequired() { return new RefreshFlowResult { - Succeeded = false, Outcome = RefreshOutcome.ReauthRequired }; } @@ -26,9 +30,14 @@ public static RefreshFlowResult Success( AccessToken? accessToken = null, RefreshTokenInfo? refreshToken = null) { + + if (outcome is not (RefreshOutcome.Success or RefreshOutcome.NoOp or RefreshOutcome.Touched or RefreshOutcome.Rotated)) + { + throw new ArgumentOutOfRangeException(nameof(outcome)); + } + return new RefreshFlowResult { - Succeeded = true, Outcome = outcome, SessionId = sessionId, AccessToken = accessToken, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs index bc4d9c9e..ec6a0c54 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -9,11 +9,11 @@ public sealed record AuthValidationResult { public required SessionState State { get; init; } public AuthStateSnapshot? Snapshot { get; init; } - public Guid? ChainId { get; init; } + public SessionChainId? ChainId { get; init; } - public Guid? RootId { get; init; } + public SessionRootId? RootId { get; init; } - public string? BoundDeviceId { get; init; } + public DeviceId? BoundDeviceId { get; init; } public bool IsValid => State == SessionState.Active; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs index 9443abab..2ff5672f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -51,7 +51,7 @@ public static SessionValidationResult Active( public static SessionValidationResult Invalid( SessionState state, - UserKey? userId = null, + UserKey? userKey = null, AuthSessionId? sessionId = null, SessionChainId? chainId = null, SessionRootId? rootId = null, @@ -59,7 +59,7 @@ public static SessionValidationResult Invalid( => new() { State = state, - UserKey = userId, + UserKey = userKey, SessionId = sessionId, ChainId = chainId, RootId = rootId, diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs index 6d5648b5..7e389170 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -1,22 +1,22 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; ///-/// Transport model for refresh token. Returned to client once upon creation. +/// Represents refresh token information intended for client-facing authentication results. /// +///+/// Contains only the raw refresh token and its expiration time. +/// Server-side security and persistence details, such as the token hash are intentionally excluded. +/// Actual token delivery is governed by the configured response policy. +/// public sealed class RefreshTokenInfo { ///- /// Plain refresh token value (returned to client once). + /// Gets the raw refresh token to be delivered to the client through the configured delivery mechanism. /// public required string Token { get; init; } ///- /// Hash of the refresh token to be persisted. - /// - public required string TokenHash { get; init; } - - ///- /// Expiration time. + /// Gets the expiration time of the refresh token in UTC. /// public required DateTimeOffset ExpiresAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs new file mode 100644 index 00000000..b3b461e4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs @@ -0,0 +1,30 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +///+/// Represents the result of issuing a refresh token for trusted server-side operations. +/// +///+/// Contains both the raw refresh token and its hash. +/// The raw token is intended for one-time delivery to the client, +/// while the hash is used exclusively for server-side persistence and rotation. +/// This type is not intended to be exposed directly in client responses. +/// Use +public sealed class RefreshTokenIssuanceResult +{ + ///for client-facing token information. +/// + /// Gets the raw refresh token generated for one-time delivery to the client. + /// + public required string Token { get; init; } + + ///+ /// Gets the hash of the refresh token used for server-side persistence, validation, and rotation. + /// Must never be exposed to the client. + /// + public required string TokenHash { get; init; } + + ///+ /// Gets the expiration time of the refresh token in UTC. + /// + public required DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs index af715f12..1e190647 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -6,9 +6,7 @@ public sealed record RefreshTokenRotationResult { public bool IsSuccess { get; init; } public bool ReauthRequired { get; init; } - public bool IsReuseDetected { get; init; } // internal use - public AuthSessionId? SessionId { get; init; } public AccessToken? AccessToken { get; init; } public RefreshTokenInfo? RefreshToken { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs index fb95004f..91f1cd5a 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -6,9 +6,8 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TokenIssuanceContext { public required UserKey UserKey { get; init; } - public TenantKey Tenant { get; init; } - public IReadOnlyDictionaryClaims { get; set; } = new Dictionary (); + public required TenantKey Tenant { get; init; } + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; public AuthSessionId? SessionId { get; init; } public SessionChainId? ChainId { get; init; } - public DateTimeOffset IssuedAt { get; init; } } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs index 78bd7e1c..687acf39 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -4,7 +4,7 @@ //public sealed class UserAuthenticationResult //{ -// public bool Succeeded { get; init; } +// public bool IsSuccess { get; init; } // public TUserId? UserId { get; init; } @@ -12,12 +12,12 @@ // public bool RequiresMfa { get; init; } -// public static UserAuthenticationResult Fail() => new() { Succeeded = false }; +// public static UserAuthenticationResult Fail() => new() { IsSuccess = false }; // public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) // => new() // { -// Succeeded = true, +// IsSuccess = true, // UserId = userId, // Claims = claims, // RequiresMfa = requiresMfa diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs index a95107d6..9fac6f84 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs @@ -1,6 +1,10 @@ using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; namespace CodeBeam.UltimateAuth.Core; + +[JsonConverter(typeof(PasswordHashJsonConverter))] public readonly record struct PasswordHash : IParsable { public string Algorithm { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs index 4bc6988f..7d28fd67 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -14,17 +14,7 @@ private AuthSessionId(string value) Value = value; } - public static bool TryCreate(string? raw, out AuthSessionId id) - { - if (IsValid(raw)) - { - id = new AuthSessionId(raw!); - return true; - } - - id = default; - return false; - } + public static bool TryCreate(string? raw, out AuthSessionId id) => TryParse(raw, null, out id); public static AuthSessionId Parse(string s, IFormatProvider? provider) { @@ -34,6 +24,8 @@ public static AuthSessionId Parse(string s, IFormatProvider? provider) throw new FormatException("Invalid AuthSessionId."); } + public static AuthSessionId Parse(string value) => Parse(value, null); + public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result) { if (IsValid(s)) diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs index 99ff48b2..7e1d1df8 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Collections.ObjectModel; +using System.Security.Claims; using System.Text.Json.Serialization; namespace CodeBeam.UltimateAuth.Core.Domain; @@ -21,7 +22,25 @@ public sealed class ClaimsSnapshot [JsonConstructor] public ClaimsSnapshot(IReadOnlyDictionary > claims) { - _claims = claims; + ArgumentNullException.ThrowIfNull(claims); + var copy = new Dictionary >(StringComparer.Ordinal); + + foreach (var (type, values) in claims) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentNullException.ThrowIfNull(values); + + var items = values.ToArray(); + + if (items.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("Claim values cannot be null or whitespace.", nameof(claims)); + } + + copy.Add(type, Array.AsReadOnly(items)); + } + + _claims = new ReadOnlyDictionary >(copy); } public static ClaimsSnapshot Empty { get; } = new(new Dictionary >()); @@ -99,11 +118,11 @@ public override int GetHashCode() { int hash = 17; - foreach (var (type, values) in Claims.OrderBy(x => x.Key)) + foreach (var (type, values) in Claims.OrderBy(x => x.Key, StringComparer.Ordinal)) { hash = hash * 23 + type.GetHashCode(); - foreach (var value in values.OrderBy(v => v)) + foreach (var value in values.OrderBy(v => v, StringComparer.Ordinal)) { hash = hash * 23 + value.GetHashCode(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs index da64faf0..a06626c0 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -2,9 +2,10 @@ public enum RefreshOutcome { - Success = 0, // minimal transport - NoOp = 10, - Touched = 20, - Rotated = 30, + None = 0, + Success = 10, // minimal transport + NoOp = 20, + Touched = 30, + Rotated = 40, ReauthRequired = 100 } diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs deleted file mode 100644 index 9099cbf6..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CodeBeam.UltimateAuth.Core.Domain; - -/// -/// Represents the minimal user abstraction required by UltimateAuth. -/// Includes the unique user identifier and an optional set of claims that -/// may be used during authentication or session creation. -/// -public interface IAuthSubject-{ - /// - /// Gets the unique identifier of the user. - /// - TUserId UserId { get; } - - ///- /// Gets an optional collection of user claims that may be used to construct - /// session-level claim snapshots. Implementations may return - IReadOnlyDictionarynull if no claims are available. - ///? Claims { get; } -} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs index e45d5220..83110647 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -43,12 +43,6 @@ public static bool TryParse(string? s, IFormatProvider? provider, out UserKey re return false; } - if (Guid.TryParse(s, out var guid)) - { - result = FromGuid(guid); - return true; - } - result = FromString(s); return true; } @@ -61,6 +55,8 @@ public static UserKey Parse(string s, IFormatProvider? provider) return result; } + public static UserKey Parse(string value) => Parse(value, null); + public override string ToString() => Value; public static implicit operator string(UserKey key) => key.Value; diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs index 8629eb13..369b85c5 100644 --- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -1,5 +1,23 @@ namespace CodeBeam.UltimateAuth.Core.Domain; +// TODO (User Management Expansion): +// UltimateAuth must not require host applications to inherit from framework +// user types or implement interfaces on their existing user entities. +// +// Host applications should integrate their user models through dedicated +// adapter/provider interfaces registered via dependency injection. +// +// Future User Management Expansion work should define and document these +// integration contracts, including user resolution, runtime state mapping, +// lifecycle integration, and claims provisioning. +// +// Keep host user entities independent from UltimateAuth contracts. +// Do not introduce a mandatory IUser or IAuthSubject dependency. + +/// +/// Represents the minimal user state required by UltimateAuth at runtime. +/// This is a framework-owned snapshot, not a host application user entity. +/// public sealed record UserRuntimeRecord { public UserKey UserKey { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs index 95299f6c..37b72535 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs @@ -8,6 +8,9 @@ public sealed class DeviceIdJsonConverter : JsonConverter{ public override DeviceId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("DeviceId must be a string."); + var value = reader.GetString(); if (!DeviceId.TryCreate(value, out var id)) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs index 24c3c08f..53864272 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs @@ -9,12 +9,12 @@ public sealed class SessionRootIdJsonConverter : JsonConverter public override SessionRootId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.String) - throw new JsonException("SessionChainId must be a string."); + throw new JsonException("SessionRootId must be a string."); var raw = reader.GetString(); if (!SessionRootId.TryCreate(raw!, out var id)) - throw new JsonException($"Invalid SessionChainId value: '{raw}'"); + throw new JsonException($"Invalid SessionRootId value: '{raw}'"); return id; } diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs index b0d42356..1f2922a2 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs @@ -19,7 +19,14 @@ public override TenantKey Read(ref Utf8JsonReader reader, Type typeToConvert, Js // IMPORTANT: // JSON transport = internal framework boundary // Do NOT use FromExternal here. - return TenantKey.FromInternal(value); + try + { + return TenantKey.FromInternal(value!); + } + catch (ArgumentException ex) + { + throw new JsonException("Invalid TenantKey.", ex); + } } public override void Write(Utf8JsonWriter writer, TenantKey value, JsonSerializerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs index 0a4da433..e7bb5742 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs @@ -14,7 +14,12 @@ public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, Json if (reader.TokenType != JsonTokenType.String) throw new JsonException("UserKey must be a string."); - return UserKey.FromString(reader.GetString()!); + var value = reader.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + throw new JsonException("UserKey cannot be null or empty."); + + return UserKey.FromString(value); } public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs index bb4458f2..d935f685 100644 --- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -14,52 +14,30 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes return SessionValidationResult.Invalid( dto.State, sessionId: sessionId, - chainId: TryParseChainId(dto.ChainId), - rootId: TryParseRootId(dto.RootId), - boundDeviceId: TryParseDeviceId(dto.BoundDeviceId)); + chainId: dto.ChainId, + rootId: dto.RootId, + boundDeviceId: dto.BoundDeviceId); } - // - // Active is a stronger contract than merely receiving - // a successful HTTP response. All required security - // lineage and identity data must be present. - // + // An active validation result must contain + // the required identity and security lineage. if (dto.Snapshot?.Identity is null) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId); } - if (dto.ChainId is not Guid chainGuid || - chainGuid == Guid.Empty) + if (dto.ChainId is not SessionChainId chainId || chainId.IsUnassigned) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId); } - var chainId = - SessionChainId.From(chainGuid); - - if (dto.RootId is not Guid rootGuid || - rootGuid == Guid.Empty) + if (dto.RootId is not SessionRootId rootId || rootId.Value == Guid.Empty) { - return SessionValidationResult.Invalid( - SessionState.Invalid, - sessionId: sessionId, - chainId: chainId); + return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId, chainId: chainId); } - var rootId = - SessionRootId.From(rootGuid); - - var identity = - dto.Snapshot.Identity; - - var boundDeviceId = - TryParseDeviceId(dto.BoundDeviceId); + var identity = dto.Snapshot.Identity; return SessionValidationResult.Active( tenant: identity.Tenant, @@ -68,10 +46,8 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes chainId: chainId, rootId: rootId, claims: dto.Snapshot.Claims, - authenticatedAt: - identity.AuthenticatedAt - ?? DateTimeOffset.UtcNow, - boundDeviceId: boundDeviceId); + authenticatedAt: identity.AuthenticatedAt ?? DateTimeOffset.UtcNow, + boundDeviceId: dto.BoundDeviceId); } public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) @@ -88,35 +64,4 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes BoundDeviceId = result.BoundDeviceId }; } - - private static SessionChainId? TryParseChainId(Guid? value) - { - if (value is not Guid guid || - guid == Guid.Empty) - { - return null; - } - - return SessionChainId.From(guid); - } - - private static SessionRootId? TryParseRootId(Guid? value) - { - if (value is not Guid guid || - guid == Guid.Empty) - { - return null; - } - - return SessionRootId.From(guid); - } - - private static DeviceId? TryParseDeviceId(string? value) - { - return DeviceId.TryCreate( - value, - out var id) - ? id - : null; - } } diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs index 949dc694..1e44eca7 100644 --- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -24,11 +24,12 @@ private TenantKey(string value) public bool IsSingle => Value == Single.Value; public bool IsSystem => Value == System.Value; public bool IsUnresolved => Value == Unresolved.Value; + public bool IsValid => !string.IsNullOrWhiteSpace(Value); /// /// True only for real, customer-defined tenants. /// - public bool IsNormal => !IsSingle && !IsSystem && !IsUnresolved; + public bool IsNormal => IsValid && !IsSingle && !IsSystem && !IsUnresolved; public static TenantKey Parse(string s, IFormatProvider? provider) { @@ -38,6 +39,8 @@ public static TenantKey Parse(string s, IFormatProvider? provider) return result; } + public static TenantKey Parse(string value) => Parse(value, null); + public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result) { result = default; @@ -50,7 +53,11 @@ public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result = FromExternal(s); return true; } - catch + catch (ArgumentException) + { + return false; + } + catch (SecurityException) { return false; } @@ -78,7 +85,15 @@ public static TenantKey FromExternal(string? value) ////// Internal creation for framework use only. /// - internal static TenantKey FromInternal(string value) => new(value); + internal static TenantKey FromInternal(string value) + { + if (value == Single.Value || value == System.Value || value == Unresolved.Value) + { + return new TenantKey(value); + } + + return new TenantKey(Normalize(value)); + } private static string Normalize(string value) { diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs index ac273a50..69702225 100644 --- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -47,4 +47,9 @@ public sealed class UAuthOptions /// validated, and optionally enforced. ///+ /// Pagination configuration for user queries, role listings, and other paged endpoints. + /// + public UAuthPaginationOptions Pagination { get; set; } = new(); } diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs new file mode 100644 index 00000000..f88e98dc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs @@ -0,0 +1,19 @@ +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Options; + +public sealed class UAuthPaginationOptions : IUAuthPaginationPolicy +{ + public const int DefaultPageSizeValue = 250; + public const int DefaultMaxPageSizeValue = 1000; + + public int DefaultPageSize { get; set; } = DefaultPageSizeValue; + + public int MaxPageSize { get; set; } = DefaultMaxPageSizeValue; + + internal UAuthPaginationOptions Clone() => new() + { + DefaultPageSize = DefaultPageSize, + MaxPageSize = MaxPageSize + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs index 9b525f5f..106a7ec7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -1,7 +1,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Server.Auth; -namespace CodeBeam.UltimateAuth.Server.Abstactions; +namespace CodeBeam.UltimateAuth.Server.Abstractions; ////// Issues access and refresh tokens according to the active auth mode. @@ -10,5 +10,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions; public interface ITokenIssuer { Task public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + ///IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); - Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); } diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs index 50bd4e05..acef799a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -35,7 +35,7 @@ public sealed class AuthFlowContext Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; public bool IsSingleTenant => Tenant.IsSingle; - public bool IsMultiTenant => !Tenant.IsSingle && !Tenant.IsSystem; + public bool IsMultiTenant => Tenant.IsNormal; internal AuthFlowContext( AuthFlowType flowType, @@ -52,8 +52,8 @@ internal AuthFlowContext( PrimaryTokenKind primaryTokenKind, ReturnUrlInfo returnUrlInfo) { - if (tenantKey.IsUnresolved) - throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + if (!tenantKey.IsValid || tenantKey.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant."); FlowType = flowType; ClientProfile = clientProfile; diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs index 3357fd43..0172602d 100644 --- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.Core.Options; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Extensions; @@ -84,8 +85,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); } - if (tenant.IsUnresolved) - throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + if (!tenant.IsValid || tenant.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant."); // TODO: Implement invariant checker //_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs index 5d6b0885..f4a1cb59 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -92,7 +92,7 @@ public async Task LoginAsync(HttpContext ctx) { Identifier = request.Identifier, Secret = request.Secret, - Factor = CredentialType.Password, + CredentialType = CredentialType.Password, PreviewReceipt = request.PreviewReceipt, RequestTokens = authFlow.AllowsTokenIssuance, Metadata = request.Metadata, @@ -152,7 +152,7 @@ public async Task TryLoginAsync(HttpContext ctx) { return Results.Ok(new TryLoginResult { - Success = false, + IsSuccess = false, Reason = AuthFailureReason.InvalidCredentials }); } @@ -161,7 +161,7 @@ public async Task TryLoginAsync(HttpContext ctx) { Identifier = request.Identifier, Secret = request.Secret, - Factor = CredentialType.Password, + CredentialType = CredentialType.Password, PreviewReceipt = request.PreviewReceipt, RequestTokens = authFlow.AllowsTokenIssuance, Metadata = request.Metadata @@ -185,7 +185,7 @@ public async Task TryLoginAsync(HttpContext ctx) var fingerprint = LoginPreviewFingerprint.Create( authFlow.Tenant, request.Identifier, - request.Factor, + request.CredentialType, request.Secret, deviceId); @@ -199,7 +199,7 @@ public async Task TryLoginAsync(HttpContext ctx) var artifact = new LoginPreviewArtifact( authFlow.Tenant, userKey, - request.Factor, + request.CredentialType, deviceId.Value, request.Identifier, authFlow.ClientProfile, @@ -212,7 +212,7 @@ public async Task TryLoginAsync(HttpContext ctx) return Results.Ok(new TryLoginResult { - Success = result.IsSuccess, + IsSuccess = result.IsSuccess, Reason = result.FailureReason, RemainingAttempts = result.RemainingAttempts, LockoutUntilUtc = result.LockoutUntilUtc, diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index cbaaf154..06f9b4fd 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -54,7 +54,7 @@ public async Task LogoutAsync(HttpContext ctx) return decision.Enabled ? Results.Redirect(decision.TargetUrl!) - : Results.Ok(new LogoutResponse { Success = true }); + : Results.Ok(new LogoutResponse { IsSuccess = true }); } public async Task LogoutDeviceSelfAsync(HttpContext ctx) diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 768c4013..519dfbe2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -99,7 +99,7 @@ public async Task TryCompleteAsync(HttpContext ctx) { return Results.Ok(new TryPkceLoginResult { - Success = false, + IsSuccess = false, RetryWithNewPkce = true }); } @@ -112,11 +112,11 @@ public async Task TryCompleteAsync(HttpContext ctx) var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); - if (!validation.Success) + if (!validation.IsSuccess) { return Results.Ok(new TryPkceLoginResult { - Success = false, + IsSuccess = false, RetryWithNewPkce = true }); } @@ -144,7 +144,7 @@ public async Task TryCompleteAsync(HttpContext ctx) return Results.Ok(new TryPkceLoginResult { - Success = preview.IsSuccess, + IsSuccess = preview.IsSuccess, Reason = preview.FailureReason, RemainingAttempts = preview.RemainingAttempts, LockoutUntilUtc = preview.LockoutUntilUtc, @@ -182,7 +182,7 @@ public async Task CompleteAsync(HttpContext ctx) if (result.InvalidPkce) return Results.Unauthorized(); - if (!result.Success) + if (!result.IsSuccess) return await RedirectToLoginWithErrorAsync(ctx, auth, "invalid"); var login = result.LoginResult!; diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs index bc59d6b0..2d4f37ee 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -51,7 +51,7 @@ public async Task RefreshAsync(HttpContext ctx) var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); - if (!result.Succeeded) + if (!result.IsSuccess) { return Results.Unauthorized(); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index bbe6fc31..84f22cde 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -76,9 +76,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Ok(new AuthValidationResult { State = result.State, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }); } @@ -88,9 +88,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken new AuthValidationResult { State = SessionState.Invalid, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }, statusCode: StatusCodes.Status401Unauthorized ); @@ -104,9 +104,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken new AuthValidationResult { State = SessionState.Invalid, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId }, statusCode: StatusCodes.Status401Unauthorized); @@ -115,9 +115,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken return Results.Ok(new AuthValidationResult { State = SessionState.Active, - ChainId = result.ChainId?.Value, - RootId = result.RootId?.Value, - BoundDeviceId = result.BoundDeviceId?.Value, + ChainId = result.ChainId, + RootId = result.RootId, + BoundDeviceId = result.BoundDeviceId, Snapshot = snapshot }); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index dfa550da..d3566994 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -13,7 +13,6 @@ using CodeBeam.UltimateAuth.Policies.Abstractions; using CodeBeam.UltimateAuth.Policies.Defaults; using CodeBeam.UltimateAuth.Policies.Registry; -using CodeBeam.UltimateAuth.Server.Abstactions; using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Authentication; @@ -63,7 +62,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s .PostConfigure(options => { // Add any default values or adjustments here if needed - }); + }) + .ValidateOnStart(); services.AddUltimateAuthServerInternal(); @@ -81,7 +81,8 @@ public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollect { configure?.Invoke(options); }) - .BindConfiguration("UltimateAuth:ResourceApi"); + .BindConfiguration("UltimateAuth:ResourceApi") + .ValidateOnStart(); services.AddUltimateAuthResourceInternal(); @@ -133,8 +134,15 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol services.AddSingleton , UAuthServerMultiTenantOptionsValidator>(); services.AddSingleton , UAuthServerUserIdentifierOptionsValidator>(); services.AddSingleton , UAuthServerSessionResolutionOptionsValidator>(); + services.AddSingleton , UAuthServerPaginationOptionsValidator>(); services.AddScoped (); + services.TryAddSingleton (sp => + { + var options = sp.GetRequiredService >().Value; + return options.Pagination; + }); + services.TryAddEnumerable(ServiceDescriptor.Singleton ()); services.TryAddEnumerable(ServiceDescriptor.Singleton ()); services.TryAddEnumerable(ServiceDescriptor.Singleton ()); diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs index f2c6a3d7..9ba89be2 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -27,20 +27,22 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) { + var status = MapStatusCode(ex); + var problem = new ProblemDetails { Title = ex.Title, Detail = ex.Code, - Status = MapStatusCode(ex), + Status = status, Type = $"{ex.TypePrefix}/{ex.Code}" }; problem.Extensions["traceId"] = context.TraceIdentifier; - context.Response.StatusCode = problem.Status ?? 500; + context.Response.StatusCode = status; context.Response.ContentType = "application/problem+json"; - return context.Response.WriteAsJsonAsync(problem); + return context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json"); } private static int MapStatusCode(UAuthRuntimeException ex) => diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 5c215621..a8b8f377 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -6,7 +6,7 @@ using CodeBeam.UltimateAuth.Core.Events; using CodeBeam.UltimateAuth.Core.Security; using CodeBeam.UltimateAuth.Credentials; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; using CodeBeam.UltimateAuth.Server.Extensions; using CodeBeam.UltimateAuth.Server.Infrastructure; @@ -71,6 +71,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req if (flow.Device.DeviceId is not DeviceId deviceId) throw new UAuthConflictException("Device id could not resolved."); + if (request.CredentialType != CredentialType.Password) + throw new UAuthValidationException("unsupported_credential_type"); + var now = _clock.UtcNow; var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct); var userKey = resolution?.UserKey; @@ -94,7 +97,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); } - factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct); + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.CredentialType, ct); if (factorState.IsLocked(now)) { @@ -107,7 +110,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req foreach (var credential in credentials) { - if (credential.IsDeleted || !credential.Security.IsUsable(now)) + if (credential.Type != request.CredentialType || credential.IsDeleted || !credential.Security.IsUsable(now)) continue; if (await provider.ValidateAsync(credential, request.Secret, ct)) @@ -168,7 +171,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req factorState = await _authenticationSecurityManager.MutateFactorAsync( flow.Tenant, userKey.Value, - request.Factor, + request.CredentialType, state => state.RegisterFailure( now, _options.Login.MaxFailedAttempts, @@ -228,7 +231,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req factorState = await _authenticationSecurityManager.MutateFactorAsync( flow.Tenant, userKey.Value, - request.Factor, + request.CredentialType, state => state.RegisterSuccess(), ct); } @@ -260,13 +263,17 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req UserKey = userKey.Value, SessionId = issuedSession.Session.SessionId, ChainId = issuedSession.Session.ChainId, - Claims = claims.AsDictionary() + Claims = claims }; + var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct); + + var refreshIssuance = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct); + tokens = new AuthTokens { - AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), - RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) + AccessToken = accessToken, + RefreshToken = refreshIssuance is null ? null : new RefreshTokenInfo { Token = refreshIssuance.Token, ExpiresAt = refreshIssuance.ExpiresAt } }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs index a6c0dae9..9573fd35 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs @@ -4,11 +4,11 @@ public sealed class PkceValidationResult { private PkceValidationResult(bool success, PkceValidationFailureReason reason) { - Success = success; + IsSuccess = success; FailureReason = reason; } - public bool Success { get; } + public bool IsSuccess { get; } public PkceValidationFailureReason FailureReason { get; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs index 9af510d5..ae22cc9e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -57,7 +57,7 @@ public void WriteInternal(HttpContext context, GrantKind kind, string value) case TokenResponseMode.Body: // TODO: Implement body writing if needed - break; + throw new NotSupportedException("Body token delivery is not supported."); } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs index a9ab6d46..b260a9f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs @@ -31,36 +31,33 @@ public string CreateToken(UAuthJwtTokenDescriptor descriptor) Claims = BuildClaims(descriptor), - SigningCredentials = new SigningCredentials( - signingKey.Key, - signingKey.Algorithm) + SigningCredentials = new SigningCredentials(signingKey.Key, signingKey.Algorithm) }; - tokenDescriptor.AdditionalHeaderClaims = new Dictionary - { - ["kid"] = signingKey.KeyId - }; - return _handler.CreateToken(tokenDescriptor); } private static IDictionary BuildClaims(UAuthJwtTokenDescriptor descriptor) { - var claims = new Dictionary - { - ["sub"] = descriptor.Subject - }; - - claims["tenant"] = descriptor.Tenant; + var claims = new Dictionary (StringComparer.Ordinal); if (descriptor.Claims is not null) { - foreach (var kv in descriptor.Claims) + foreach (var (type, value) in descriptor.Claims) { - claims[kv.Key] = kv.Value; + // These claims are controlled by SecurityTokenDescriptor + // or explicitly defined by UAuthJwtTokenDescriptor. + if (type is "sub" or "tenant" or "iss" or "aud" or "exp" or "nbf" or "iat") + continue; + + claims[type] = value; } } + // Framework-owned identity claims always take precedence. + claims["sub"] = descriptor.Subject; + claims["tenant"] = descriptor.Tenant.Value; + return claims; } } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs index c4d139fb..1649494c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -3,7 +3,7 @@ using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Options; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Infrastructure; @@ -40,7 +40,7 @@ public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuan { // TODO: Discuss, Hybrid token may be JWT. UAuthMode.PureOpaque or UAuthMode.Hybrid => - Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())), + Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)), UAuthMode.SemiHybrid or UAuthMode.PureJwt => @@ -50,7 +50,7 @@ UAuthMode.SemiHybrid or }; } - public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) { if (flow.EffectiveMode == UAuthMode.PureOpaque) return null; @@ -84,7 +84,7 @@ await store.ExecuteAsync(async transactionCt => }, ct); } - return new RefreshTokenInfo + return new RefreshTokenIssuanceResult { Token = raw, TokenHash = hash, @@ -92,7 +92,7 @@ await store.ExecuteAsync(async transactionCt => }; } - private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, AuthSessionId? sessionId) { string token = _opaqueGenerator.Generate(); @@ -101,26 +101,48 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi Token = token, Format = TokenFormat.Opaque, ExpiresAt = expires, - SessionId = sessionId + SessionId = sessionId?.Value }; } private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires) { - var claims = new Dictionary (); + var claims = new Dictionary (StringComparer.Ordinal); // Custom/application claims are added first. // Framework-owned security claims below always take precedence. - foreach (var kv in context.Claims) - claims[kv.Key] = kv.Value; + foreach (var (type, values) in context.Claims.Claims) + { + if (values.Count == 0) + continue; + + // Framework - owned claims cannot be supplied by applications. + if (type is + "sub" or + "tenant" or + "sid" or + "jti" or + "iss" or + "aud" or + "exp" or + "nbf" or + "iat") + continue; + + claims[type] = values.Count == 1 + ? values.First() + : values.ToArray(); + } // UltimateAuth-owned identity/security claims must never be overridable // by caller-provided claims. claims["sub"] = context.UserKey.Value; - claims["tenant"] = context.Tenant; + claims["tenant"] = context.Tenant.Value; if (context.SessionId is AuthSessionId sessionId) - claims["sid"] = sessionId; + claims["sid"] = sessionId.Value; + else + claims.Remove("sid"); if (tokens.AddJwtIdClaim) claims["jti"] = _opaqueGenerator.GenerateJwtId(); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs index 01d91819..c8a3b6ff 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs @@ -45,7 +45,7 @@ public AccessDecision Decide(AccessContext context, IEnumerable r var result = policy.Decide(context); - if (!result.IsAllowed) + if (result.IsDenied) return result; if (result.RequiresReauthentication) diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs index 6fe26b2d..d96af659 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -30,12 +30,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); + if (decision.RequiresReauthentication) + throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication."); + if (!decision.IsAllowed) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); - await command.ExecuteAsync(ct); } @@ -48,12 +48,12 @@ public async Task ExecuteAsync (AccessContext context, IAccessC var policies = _policyProvider.GetPolicies(context); var decision = _authority.Decide(context, policies); + if (decision.RequiresReauthentication) + throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication."); + if (!decision.IsAllowed) throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); - if (decision.RequiresReauthentication) - throw new InvalidOperationException("Requires reauthentication."); - return await command.ExecuteAsync(ct); } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs new file mode 100644 index 00000000..8b23a309 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs @@ -0,0 +1,14 @@ +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Contracts; + +internal static class RefreshTokenMapper +{ + public static RefreshTokenInfo ToTransport(RefreshTokenIssuanceResult result) + { + return new RefreshTokenInfo + { + Token = result.Token, + ExpiresAt = result.ExpiresAt + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 97652d24..7e91f86b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -43,6 +43,11 @@ public sealed class UAuthServerOptions /// + /// Controls the default and maximum page sizes for paginated queries. + /// + public UAuthPaginationOptions Pagination { get; set; } = new(); + // ------------------------------------------------------- // SERVER-ONLY BEHAVIOR // ------------------------------------------------------- @@ -139,9 +144,10 @@ internal UAuthServerOptions Clone() Pkce = Pkce.Clone(), Events = Events.Clone(), MultiTenant = MultiTenant.Clone(), + Pagination = Pagination.Clone(), + Cookie = Cookie.Clone(), Diagnostics = Diagnostics.Clone(), - PrimaryCredential = PrimaryCredential.Clone(), AuthResponse = AuthResponse.Clone(), ResetCredential = ResetCredential.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs new file mode 100644 index 00000000..dfa3079e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerPaginationOptionsValidator : IValidateOptions+{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List (); + + if (options.Pagination is null) + { + errors.Add("Pagination configuration cannot be null."); + return ValidateOptionsResult.Fail(errors); + } + + var pagination = options.Pagination; + + if (pagination.DefaultPageSize <= 0) + { + errors.Add("Pagination.DefaultPageSize must be greater than zero."); + } + + if (pagination.MaxPageSize <= 0) + { + errors.Add("Pagination.MaxPageSize must be greater than zero."); + } + + if (pagination.DefaultPageSize > pagination.MaxPageSize) + { + errors.Add("Pagination.DefaultPageSize cannot exceed Pagination.MaxPageSize."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs index 4445c7e1..7745ccb6 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs @@ -4,6 +4,8 @@ namespace CodeBeam.UltimateAuth.Server.Services; +// TODO: Implement external login, MFA, and reauthentication flows as commented out in the interface below. + /// /// Handles authentication flows such as login, /// logout, session refresh and reauthentication. @@ -14,17 +16,17 @@ public interface IUAuthFlowService Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); - Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); + //Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); - Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); + //Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default); - Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); + //Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default); Task LogoutAsync(LogoutRequest request, CancellationToken ct = default); Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default); - Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); + //Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default); } internal interface IUAuthInternalFlowService diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs index ec17b4c9..44b15f1e 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -86,13 +86,13 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow); - if (!validation.Success) + if (!validation.IsSuccess) { artifact.RegisterAttempt(); return new PkceCompleteResult { - Success = false, + IsSuccess = false, FailureReason = AuthFailureReason.InvalidCredentials }; } @@ -114,7 +114,7 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo return new PkceCompleteResult { - Success = result.IsSuccess, + IsSuccess = result.IsSuccess, FailureReason = result.FailureReason, LoginResult = result }; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 75f77923..fbdc931b 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -1,9 +1,10 @@ -using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; -using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Abstractions; using CodeBeam.UltimateAuth.Server.Auth; namespace CodeBeam.UltimateAuth.Server.Services; @@ -13,12 +14,15 @@ public sealed class RefreshTokenRotationService : IRefreshTokenRotationService private readonly IRefreshTokenValidator _validator; private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; + private readonly IUserClaimsProvider _claimsProvider; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer) + + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IUserClaimsProvider claimsProvider) { _validator = validator; _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; + _claimsProvider = claimsProvider; } // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. @@ -71,6 +75,8 @@ public async Task RotateAsync(AuthFlowContext flo var store = _storeFactory.Create(validation.Tenant); + var claims = await _claimsProvider.GetClaimsAsync(validation.Tenant, userKey, ct); + var tokenContext = new TokenIssuanceContext { Tenant = flow.OriginalOptions.MultiTenant.Enabled @@ -79,7 +85,8 @@ public async Task RotateAsync(AuthFlowContext flo UserKey = userKey, SessionId = sessionId, - ChainId = validation.ChainId + ChainId = validation.ChainId, + Claims = claims }; // Generate candidate replacement refresh token. @@ -125,6 +132,11 @@ public async Task RotateAsync(AuthFlowContext flo // Only the winning request needs an access token. var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + var refreshTokenInfo = new RefreshTokenInfo + { + Token = refreshToken.Token, + ExpiresAt = refreshToken.ExpiresAt + }; return new RefreshTokenRotationExecution { @@ -132,8 +144,7 @@ public async Task RotateAsync(AuthFlowContext flo UserKey = userKey, SessionId = sessionId, ChainId = validation.ChainId, - - Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) + Result = RefreshTokenRotationResult.Success(accessToken, refreshTokenInfo) }; } diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index c993e957..ff3b6e77 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -10,12 +10,14 @@ internal sealed class SessionApplicationService : ISessionApplicationService { private readonly IAccessOrchestrator _accessOrchestrator; private readonly ISessionStoreFactory _storeFactory; + private readonly IUAuthPaginationPolicy _pagination; private readonly IClock _clock; - public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock) + public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IUAuthPaginationPolicy pagination, IClock clock) { _accessOrchestrator = accessOrchestrator; _storeFactory = storeFactory; + _pagination = pagination; _clock = clock; } @@ -24,7 +26,7 @@ public async Task > GetUserChainsAsync(AccessCon var command = new AccessCommand >(async innerCt => { var store = _storeFactory.Create(context.ResourceTenant); - request = request.Normalize(); + request = request.Normalize(_pagination); var chains = await store.GetChainsByUserAsync(userKey); var actorChainId = context.ActorChainId; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs index 85011c70..df1d2e57 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -113,23 +113,23 @@ public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct } } - public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } - - public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) - { - throw new NotImplementedException(); - } + //public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} + + //public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default) + //{ + // throw new NotImplementedException(); + //} } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs index 822da088..d8c54b4f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -1,21 +1,31 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; +using System.Text.Json.Serialization; -public readonly record struct Permission(string Value) +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct Permission { - public static Permission From(string value) + public string Value { get; } + + [JsonConstructor] + public Permission(string value) { if (string.IsNullOrWhiteSpace(value)) - throw new ArgumentException("permission_required"); + throw new ArgumentException("permission_required", nameof(value)); - return new Permission(value.Trim().ToLowerInvariant()); + Value = value.Trim().ToLowerInvariant(); } + public static Permission From(string value) => new(value); + public static readonly Permission Wildcard = new("*"); public bool IsWildcard => Value == "*"; - public bool IsPrefix => Value.EndsWith(".*"); - public override string ToString() => Value; + public bool IsPrefix => Value?.EndsWith(".*", StringComparison.Ordinal) == true; + + public bool IsValid => !string.IsNullOrWhiteSpace(Value); + + public override string ToString() => Value ?? string.Empty; public static implicit operator string(Permission p) => p.Value; } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs index 1046ce61..a1403eb3 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -3,6 +3,6 @@ public sealed record AuthorizationCheckRequest { public required string Action { get; init; } - public string? Resource { get; init; } + public required string Resource { get; init; } public string? ResourceId { get; init; } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs deleted file mode 100644 index 4e3f0f33..00000000 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CodeBeam.UltimateAuth.Authorization.Contracts; - -public sealed record AuthorizationRequest -{ - /// - /// Logical operation being requested (e.g. "orders.read"). - /// - public required string Operation { get; init; } - - ///- /// Optional resource identifier (row, entity, aggregate, etc). - /// - public string? Resource { get; init; } - - ///- /// Optional resource identifier. - /// - public string? ResourceId { get; init; } - - ///- /// Optional contextual attributes for fine-grained access decisions. - /// - public IReadOnlyDictionary? Attributes { get; init; } -} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs index 9412811b..3f2e9820 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -10,11 +11,13 @@ internal sealed class EfCoreRoleStore : IRoleStore where TDbContext { private readonly TDbContext _db; private readonly TenantKey _tenant; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant) + public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination) { _db = db; _tenant = tenant.Tenant; + _pagination = pagination; } private DbSet DbSetRole => _db.Set (); @@ -218,7 +221,7 @@ public async Task > GetByIdsAsync( // TODO: Add UltimateAuth standard: tiebreaker for sorting fields that are not unique. public async Task > QueryAsync(RoleQuery query, CancellationToken ct = default) { - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = DbSetRole .AsNoTracking() diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs index d6bf6afe..a26c9be1 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; using Microsoft.EntityFrameworkCore; namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; @@ -6,14 +7,16 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory where TDbContext : DbContext { private readonly TDbContext _db; + private readonly IUAuthPaginationPolicy _pagination; - public EfCoreRoleStoreFactory(TDbContext db) + public EfCoreRoleStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination) { _db = db; + _pagination = pagination; } public IRoleStore Create(TenantKey tenant) { - return new EfCoreRoleStore (_db, new TenantExecutionContext(tenant)); + return new EfCoreRoleStore (_db, new TenantExecutionContext(tenant), _pagination); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs index 78075797..e7f81625 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Errors; using CodeBeam.UltimateAuth.Core.MultiTenancy; @@ -9,9 +10,11 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory; internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore , IRoleStore { protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + private readonly IUAuthPaginationPolicy _pagination; - public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext) + public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext) { + _pagination = pagination; } protected override void BeforeAdd(Role entity) @@ -67,7 +70,7 @@ public Task > QueryAsync(RoleQuery query, CancellationToken ct { ct.ThrowIfCancellationRequested(); - var normalized = query.Normalize(); + var normalized = query.Normalize(_pagination); var baseQuery = TenantValues().AsQueryable(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs index b51d4f2a..346cf043 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.MultiTenancy; using CodeBeam.UltimateAuth.InMemory; @@ -8,14 +9,16 @@ public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory { private readonly ConcurrentDictionary _stores = new(); private readonly InMemoryAtomicContextAccessor _atomicContext; + private readonly IUAuthPaginationPolicy _pagination; - public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext) + public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) { _atomicContext = atomicContext; + _pagination = pagination; } public IRoleStore Create(TenantKey tenant) { - return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext)); + return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext, _pagination)); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index 08ba606f..f84692c5 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -1,4 +1,5 @@ using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; @@ -39,11 +40,10 @@ public async Task CheckAsync(HttpContext ctx) var req = await ctx.ReadJsonAsync (ctx.RequestAborted); - if (string.IsNullOrWhiteSpace(req.Resource)) - return Results.BadRequest("Resource is required for authorization check."); - - if (string.IsNullOrWhiteSpace(req.Action)) - return Results.BadRequest("Action is required for authorization check."); + if (req is null || string.IsNullOrWhiteSpace(req.Action) || string.IsNullOrWhiteSpace(req.Resource)) + { + return Results.BadRequest("Action and Resource are required."); + } var accessContext = await _accessContextFactory.CreateAsync( flow, @@ -119,6 +119,10 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) var req = await ctx.ReadJsonAsync (ctx.RequestAborted); + // These are transport rules, not business rules. The business rules are enforced in the service layer. + if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName)) + return Results.BadRequest("Invalid role assignment request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.AssignAdmin, @@ -138,6 +142,9 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) var req = await ctx.ReadJsonAsync (ctx.RequestAborted); + if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName)) + return Results.BadRequest("Invalid role removal request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.RemoveAdmin, @@ -178,6 +185,9 @@ public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) var req = await ctx.ReadJsonAsync (ctx.RequestAborted); + if (req is null || req.Id != roleId || string.IsNullOrWhiteSpace(req.Name)) + return Results.BadRequest("Invalid role rename request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.RenameAdmin, @@ -199,6 +209,9 @@ public async Task DeleteRoleAsync(RoleId roleId, HttpContext ctx) var req = await ctx.ReadJsonAsync (ctx.RequestAborted); + if (req is null || req.Id != roleId) + return Results.BadRequest("Invalid role deletion request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.DeleteAdmin, @@ -219,6 +232,9 @@ public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ct var req = await ctx.ReadJsonAsync (ctx.RequestAborted); + if (req is null || req.RoleId != roleId || req.Permissions is null) + return Results.BadRequest("Invalid role permissions request."); + var accessContext = await _accessContextFactory.CreateAsync( flow, action: UAuthActions.Authorization.Roles.SetPermissionsAdmin, @@ -248,6 +264,21 @@ public async Task QueryRolesAsync(HttpContext ctx) var result = await _roles.QueryAsync(accessContext, req, ctx.RequestAborted); - return Results.Ok(result); + var response = new PagedResult ( + result.Items.Select(role => new RoleInfo + { + Id = role.Id, + Name = role.Name, + Permissions = role.Permissions.ToArray(), + CreatedAt = role.CreatedAt, + UpdatedAt = role.UpdatedAt + }).ToArray(), + result.TotalCount, + result.PageNumber, + result.PageSize, + result.SortBy, + result.Descending); + + return Results.Ok(response); } } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 9a3051d1..251eeb77 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -12,13 +12,15 @@ internal sealed class UserRoleService : IUserRoleService private readonly IAccessOrchestrator _accessOrchestrator; private readonly IUserRoleStoreFactory _userRoleFactory; private readonly IRoleStoreFactory _roleFactory; + private readonly IUAuthPaginationPolicy _pagination; private readonly IClock _clock; - public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IClock clock) + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IUAuthPaginationPolicy pagination, IClock clock) { _accessOrchestrator = accessOrchestrator; _userRoleFactory = userRoleFactory; _roleFactory = roleFactory; + _pagination = pagination; _clock = clock; } @@ -26,6 +28,8 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey); + var now = _clock.UtcNow; var cmd = new AccessCommand(async innerCt => @@ -48,6 +52,8 @@ public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, stri { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey); + var cmd = new AccessCommand(async innerCt => { var roleStore = _roleFactory.Create(context.ResourceTenant); @@ -68,9 +74,11 @@ public async Task > GetRolesAsync(AccessContext context { ct.ThrowIfCancellationRequested(); + ValidateTargetUser(context, targetUserKey, allowSelf: true); + var cmd = new AccessCommand >(async innerCt => { - request = request.Normalize(); + request = request.Normalize(_pagination); var roleStore = _roleFactory.Create(context.ResourceTenant); var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); @@ -132,4 +140,17 @@ public async Task > GetRolesAsync(AccessContext context return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); } + + private static void ValidateTargetUser(AccessContext context, UserKey targetUserKey, bool allowSelf = false) + { + if (context.TargetUserKey == targetUserKey) + return; + + if (allowSelf && + context.TargetUserKey is null && + context.ActorUserKey == targetUserKey) + return; + + throw new UAuthAuthorizationException("target_user_mismatch"); + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 38c7fe96..510acac1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -83,9 +83,6 @@ public async Task SendJsonAsync(string endpoint, object? p if (result.Status == 0) throw new UAuthTransportException("Network error."); - //if (result.Status >= 500) - // throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status); - return result; } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index a1eddceb..01565046 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -57,33 +57,59 @@ window.uauth.submitForm = function (form) { window.uauth.tryAndCommit = async function (options) { const { tryUrl, commitUrl, data, clientProfile } = options; + const deviceId = window.uauth.deviceId; + + if (!deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + const tryResponse = await window.uauth.postJson({ url: tryUrl, payload: data, - clientProfile: clientProfile + clientProfile }); - let result = tryResponse?.body; + if (!tryResponse || !Number.isInteger(tryResponse.status)) { + throw new Error("Invalid UAuth transport response."); + } + + const status = tryResponse.status; + const isHttpSuccess = status >= 200 && status < 300; + const isClientError = status >= 400 && status < 500; + + if (!isHttpSuccess && !isClientError) { + throw new Error(`Unexpected UAuth try response: HTTP ${status}.`); + } + + const result = tryResponse.body; + + if (!result || + typeof result !== "object" || + Array.isArray(result) || + typeof result.isSuccess !== "boolean") { + throw new Error("Invalid UAuth try response body."); + } - if (!result) { - result = {}; + if (isClientError && result.isSuccess) { + throw new Error("Successful authentication result returned with HTTP error."); } const normalized = { - success: result.success ?? false, + isSuccess: result.isSuccess, reason: result.reason ?? null, remainingAttempts: result.remainingAttempts ?? null, lockoutUntilUtc: result.lockoutUntilUtc ?? null, requiresMfa: result.requiresMfa ?? false, - retryWithNewPkce: result.retryWithNewPkce ?? false + retryWithNewPkce: result.retryWithNewPkce ?? false, + previewReceipt: result.previewReceipt ?? null }; - if (normalized.success) { + if (normalized.isSuccess) { const form = document.createElement("form"); form.method = "POST"; form.action = commitUrl; - for (const key in data) { + for (const key of Object.keys(data)) { const input = document.createElement("input"); input.type = "hidden"; input.name = key; @@ -91,6 +117,14 @@ window.uauth.tryAndCommit = async function (options) { form.appendChild(input); } + if (normalized.previewReceipt) { + const receipt = document.createElement("input"); + receipt.type = "hidden"; + receipt.name = "PreviewReceipt"; + receipt.value = normalized.previewReceipt; + form.appendChild(receipt); + } + const cp = document.createElement("input"); cp.type = "hidden"; cp.name = "__uauth_client_profile"; @@ -100,7 +134,7 @@ window.uauth.tryAndCommit = async function (options) { const udid = document.createElement("input"); udid.type = "hidden"; udid.name = "__uauth_device"; - udid.value = window.uauth.deviceId; + udid.value = deviceId; form.appendChild(udid); document.body.appendChild(form); diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 61a3dfaf..197576de 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:h,commitUrl:c,data:f,clientProfile:e}=n,o=window.uauth.deviceId;if(!o)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:h,payload:f,clientProfile:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,l=i>=200&&i<300,s=i>=400&&i<500;if(!l&&!s)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(s&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=c;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=e??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=o;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs index 8e9defcb..a32446b1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs @@ -5,9 +5,16 @@ namespace CodeBeam.UltimateAuth.Client.Extensions; internal static class LoginRequestFormExtensions { public static IDictionary ToDictionary(this LoginRequest request) - => new Dictionary + { + var payload = new Dictionary { ["Identifier"] = request.Identifier, ["Secret"] = request.Secret }; + + if (!string.IsNullOrWhiteSpace(request.PreviewReceipt)) + payload["PreviewReceipt"] = request.PreviewReceipt; + + return payload; + } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 2156fafa..da59de93 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -8,39 +8,39 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; internal static class UAuthResultMapper { - private static readonly JsonSerializerOptions _jsonOptions = - new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; public static UAuthResult FromJson (UAuthTransportResult raw) { EnsureTransport(raw); - if (raw.Status >= 400 && raw.Status < 500) + if (!IsSuccessStatus(raw.Status)) { - var problem = TryDeserializeProblem(raw); + var problem = CreateProblem(raw); return new UAuthResult { IsSuccess = false, Status = raw.Status, - Problem = problem + Problem = problem, + TraceId = problem?.TraceId }; } - if (raw.Body is null) + if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) { - return new UAuthResult - { - IsSuccess = true, - Status = raw.Status, - Value = default - }; + throw new UAuthProtocolException($"Expected response body for {typeof(T).Name}, but received none."); } try { var value = raw.Body.Value.Deserialize (_jsonOptions); + if (value is null) + { + throw new UAuthProtocolException($"Response body for {typeof(T).Name} was null."); + } + return new UAuthResult { IsSuccess = true, @@ -54,27 +54,84 @@ public static UAuthResult FromJson (UAuthTransportResult raw) } } - public static UAuthResult From(UAuthTransportResult raw) => FromJson